Initial commit: CloudRestoreAS v1.0.0 - Aplicación completa de restauración automática SQL Server
This commit is contained in:
1
app/ui/__init__.py
Normal file
1
app/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Interfaz gráfica de usuario."""
|
||||
399
app/ui/config_tab.py
Normal file
399
app/ui/config_tab.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""Tab de configuración."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QFormLayout, QLineEdit, QPushButton, QSpinBox,
|
||||
QCheckBox, QFileDialog, QMessageBox, QScrollArea
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from ..utils.crypto import encrypt_password, decrypt_password
|
||||
from ..extract.seven_zip import SevenZipExtractor
|
||||
from ..sql.sql_manager import SQLServerManager
|
||||
|
||||
|
||||
class ConfigTab(QWidget):
|
||||
"""Tab de configuración de la aplicación."""
|
||||
|
||||
def __init__(self, engine):
|
||||
"""Inicializa el tab."""
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self.config = {}
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
# Scroll area para la configuración
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Grupo: Carpetas
|
||||
paths_group = QGroupBox("Carpetas")
|
||||
paths_layout = QFormLayout()
|
||||
|
||||
self.input_folder_input = QLineEdit()
|
||||
input_browse_btn = QPushButton("📁")
|
||||
input_browse_btn.clicked.connect(lambda: self._browse_folder(self.input_folder_input))
|
||||
input_layout = QHBoxLayout()
|
||||
input_layout.addWidget(self.input_folder_input)
|
||||
input_layout.addWidget(input_browse_btn)
|
||||
paths_layout.addRow("Carpeta de Entrada:", input_layout)
|
||||
|
||||
self.processed_folder_input = QLineEdit()
|
||||
processed_browse_btn = QPushButton("📁")
|
||||
processed_browse_btn.clicked.connect(lambda: self._browse_folder(self.processed_folder_input))
|
||||
processed_layout = QHBoxLayout()
|
||||
processed_layout.addWidget(self.processed_folder_input)
|
||||
processed_layout.addWidget(processed_browse_btn)
|
||||
paths_layout.addRow("Carpeta Procesados:", processed_layout)
|
||||
|
||||
self.failed_folder_input = QLineEdit()
|
||||
failed_browse_btn = QPushButton("📁")
|
||||
failed_browse_btn.clicked.connect(lambda: self._browse_folder(self.failed_folder_input))
|
||||
failed_layout = QHBoxLayout()
|
||||
failed_layout.addWidget(self.failed_folder_input)
|
||||
failed_layout.addWidget(failed_browse_btn)
|
||||
paths_layout.addRow("Carpeta Fallados:", failed_layout)
|
||||
|
||||
self.extract_folder_input = QLineEdit()
|
||||
extract_browse_btn = QPushButton("📁")
|
||||
extract_browse_btn.clicked.connect(lambda: self._browse_folder(self.extract_folder_input))
|
||||
extract_layout = QHBoxLayout()
|
||||
extract_layout.addWidget(self.extract_folder_input)
|
||||
extract_layout.addWidget(extract_browse_btn)
|
||||
paths_layout.addRow("Carpeta Extracción:", extract_layout)
|
||||
|
||||
self.data_sql_folder_input = QLineEdit()
|
||||
data_browse_btn = QPushButton("📁")
|
||||
data_browse_btn.clicked.connect(lambda: self._browse_folder(self.data_sql_folder_input))
|
||||
data_layout = QHBoxLayout()
|
||||
data_layout.addWidget(self.data_sql_folder_input)
|
||||
data_layout.addWidget(data_browse_btn)
|
||||
paths_layout.addRow("Carpeta Data SQL:", data_layout)
|
||||
|
||||
self.seven_zip_input = QLineEdit()
|
||||
seven_zip_browse_btn = QPushButton("📁")
|
||||
seven_zip_browse_btn.clicked.connect(self._browse_seven_zip)
|
||||
seven_zip_detect_btn = QPushButton("🔍 Detectar")
|
||||
seven_zip_detect_btn.clicked.connect(self._detect_seven_zip)
|
||||
seven_zip_layout = QHBoxLayout()
|
||||
seven_zip_layout.addWidget(self.seven_zip_input)
|
||||
seven_zip_layout.addWidget(seven_zip_browse_btn)
|
||||
seven_zip_layout.addWidget(seven_zip_detect_btn)
|
||||
paths_layout.addRow("7-Zip (7z.exe):", seven_zip_layout)
|
||||
|
||||
paths_group.setLayout(paths_layout)
|
||||
layout.addWidget(paths_group)
|
||||
|
||||
# Grupo: SQL Server
|
||||
sql_group = QGroupBox("SQL Server")
|
||||
sql_layout = QFormLayout()
|
||||
|
||||
self.sql_server_input = QLineEdit()
|
||||
self.sql_server_input.setPlaceholderText("localhost o IP")
|
||||
sql_layout.addRow("Servidor:", self.sql_server_input)
|
||||
|
||||
self.sql_windows_auth_checkbox = QCheckBox()
|
||||
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)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
self.sql_username_input.setEnabled(False)
|
||||
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
|
||||
|
||||
self.sql_password_input = QLineEdit()
|
||||
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.sql_password_input.setEnabled(False)
|
||||
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
|
||||
|
||||
test_sql_btn = QPushButton("🔌 Probar Conexión")
|
||||
test_sql_btn.clicked.connect(self._test_sql_connection)
|
||||
sql_layout.addRow("", test_sql_btn)
|
||||
|
||||
sql_group.setLayout(sql_layout)
|
||||
layout.addWidget(sql_group)
|
||||
|
||||
# Grupo: Concurrencia
|
||||
concurrency_group = QGroupBox("Concurrencia")
|
||||
concurrency_layout = QFormLayout()
|
||||
|
||||
self.extract_workers_spinbox = QSpinBox()
|
||||
self.extract_workers_spinbox.setMinimum(1)
|
||||
self.extract_workers_spinbox.setMaximum(10)
|
||||
concurrency_layout.addRow("Workers de Extracción:", self.extract_workers_spinbox)
|
||||
|
||||
self.restore_workers_spinbox = QSpinBox()
|
||||
self.restore_workers_spinbox.setMinimum(1)
|
||||
self.restore_workers_spinbox.setMaximum(5)
|
||||
concurrency_layout.addRow("Workers de Restore:", self.restore_workers_spinbox)
|
||||
|
||||
concurrency_group.setLayout(concurrency_layout)
|
||||
layout.addWidget(concurrency_group)
|
||||
|
||||
# Grupo: Estabilidad de Archivos
|
||||
stability_group = QGroupBox("Estabilidad de Archivos")
|
||||
stability_layout = QFormLayout()
|
||||
|
||||
self.stability_enabled_checkbox = QCheckBox()
|
||||
self.stability_enabled_checkbox.setChecked(True)
|
||||
stability_layout.addRow("Verificar Estabilidad:", self.stability_enabled_checkbox)
|
||||
|
||||
self.stability_interval_spinbox = QSpinBox()
|
||||
self.stability_interval_spinbox.setMinimum(1)
|
||||
self.stability_interval_spinbox.setMaximum(60)
|
||||
self.stability_interval_spinbox.setSuffix(" s")
|
||||
stability_layout.addRow("Intervalo de Verificación:", self.stability_interval_spinbox)
|
||||
|
||||
self.stable_duration_spinbox = QSpinBox()
|
||||
self.stable_duration_spinbox.setMinimum(1)
|
||||
self.stable_duration_spinbox.setMaximum(300)
|
||||
self.stable_duration_spinbox.setSuffix(" s")
|
||||
stability_layout.addRow("Duración Estable:", self.stable_duration_spinbox)
|
||||
|
||||
self.ready_marker_checkbox = QCheckBox()
|
||||
stability_layout.addRow("Usar Marcador .ready:", self.ready_marker_checkbox)
|
||||
|
||||
stability_group.setLayout(stability_layout)
|
||||
layout.addWidget(stability_group)
|
||||
|
||||
# Grupo: Timeouts
|
||||
timeouts_group = QGroupBox("Timeouts")
|
||||
timeouts_layout = QFormLayout()
|
||||
|
||||
self.extract_timeout_spinbox = QSpinBox()
|
||||
self.extract_timeout_spinbox.setMinimum(1)
|
||||
self.extract_timeout_spinbox.setMaximum(240)
|
||||
self.extract_timeout_spinbox.setSuffix(" min")
|
||||
timeouts_layout.addRow("Extracción:", self.extract_timeout_spinbox)
|
||||
|
||||
self.restore_timeout_spinbox = QSpinBox()
|
||||
self.restore_timeout_spinbox.setMinimum(1)
|
||||
self.restore_timeout_spinbox.setMaximum(480)
|
||||
self.restore_timeout_spinbox.setSuffix(" min")
|
||||
timeouts_layout.addRow("Restauración:", self.restore_timeout_spinbox)
|
||||
|
||||
timeouts_group.setLayout(timeouts_layout)
|
||||
layout.addWidget(timeouts_group)
|
||||
|
||||
# Grupo: Características
|
||||
features_group = QGroupBox("Características")
|
||||
features_layout = QFormLayout()
|
||||
|
||||
self.dry_run_checkbox = QCheckBox()
|
||||
features_layout.addRow("Modo Dry Run:", self.dry_run_checkbox)
|
||||
|
||||
self.auto_scan_checkbox = QCheckBox()
|
||||
self.auto_scan_checkbox.setChecked(True)
|
||||
features_layout.addRow("Escaneo Automático:", self.auto_scan_checkbox)
|
||||
|
||||
self.scan_interval_spinbox = QSpinBox()
|
||||
self.scan_interval_spinbox.setMinimum(10)
|
||||
self.scan_interval_spinbox.setMaximum(600)
|
||||
self.scan_interval_spinbox.setSuffix(" s")
|
||||
features_layout.addRow("Intervalo de Escaneo:", self.scan_interval_spinbox)
|
||||
|
||||
features_group.setLayout(features_layout)
|
||||
layout.addWidget(features_group)
|
||||
|
||||
# Botones de acción
|
||||
actions_layout = QHBoxLayout()
|
||||
|
||||
save_btn = QPushButton("💾 Guardar Configuración")
|
||||
save_btn.clicked.connect(self._save_config)
|
||||
actions_layout.addWidget(save_btn)
|
||||
|
||||
actions_layout.addStretch()
|
||||
|
||||
layout.addLayout(actions_layout)
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(container)
|
||||
|
||||
# Layout principal
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def load_config(self, config: dict):
|
||||
"""Carga la configuración en la UI."""
|
||||
self.config = config
|
||||
|
||||
# Carpetas
|
||||
paths = config.get("paths", {})
|
||||
self.input_folder_input.setText(paths.get("input_folder", ""))
|
||||
self.processed_folder_input.setText(paths.get("processed_folder", ""))
|
||||
self.failed_folder_input.setText(paths.get("failed_folder", ""))
|
||||
self.extract_folder_input.setText(paths.get("extract_folder", ""))
|
||||
self.data_sql_folder_input.setText(paths.get("data_sql_folder", ""))
|
||||
self.seven_zip_input.setText(paths.get("seven_zip_exe", ""))
|
||||
|
||||
# SQL
|
||||
sql = config.get("sql", {})
|
||||
self.sql_server_input.setText(sql.get("server", "localhost"))
|
||||
self.sql_windows_auth_checkbox.setChecked(sql.get("use_windows_auth", True))
|
||||
self.sql_username_input.setText(sql.get("username", ""))
|
||||
|
||||
# Descifrar password si existe
|
||||
encrypted_pass = sql.get("password_encrypted", "")
|
||||
if encrypted_pass:
|
||||
try:
|
||||
decrypted = decrypt_password(encrypted_pass)
|
||||
self.sql_password_input.setText(decrypted)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Concurrencia
|
||||
concurrency = config.get("concurrency", {})
|
||||
self.extract_workers_spinbox.setValue(concurrency.get("extract_workers", 1))
|
||||
self.restore_workers_spinbox.setValue(concurrency.get("restore_workers", 1))
|
||||
|
||||
# Estabilidad
|
||||
stability = config.get("stability", {})
|
||||
self.stability_enabled_checkbox.setChecked(stability.get("check_enabled", True))
|
||||
self.stability_interval_spinbox.setValue(stability.get("check_interval_seconds", 5))
|
||||
self.stable_duration_spinbox.setValue(stability.get("stable_duration_seconds", 10))
|
||||
self.ready_marker_checkbox.setChecked(stability.get("use_ready_marker", False))
|
||||
|
||||
# Timeouts
|
||||
timeouts = config.get("timeouts", {})
|
||||
self.extract_timeout_spinbox.setValue(timeouts.get("extract_minutes", 30))
|
||||
self.restore_timeout_spinbox.setValue(timeouts.get("restore_minutes", 60))
|
||||
|
||||
# Características
|
||||
features = config.get("features", {})
|
||||
self.dry_run_checkbox.setChecked(features.get("dry_run_mode", False))
|
||||
self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True))
|
||||
self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30))
|
||||
|
||||
def _save_config(self):
|
||||
"""Guarda la configuración."""
|
||||
# Construir config
|
||||
config = {
|
||||
"paths": {
|
||||
"input_folder": self.input_folder_input.text(),
|
||||
"processed_folder": self.processed_folder_input.text(),
|
||||
"failed_folder": self.failed_folder_input.text(),
|
||||
"extract_folder": self.extract_folder_input.text(),
|
||||
"data_sql_folder": self.data_sql_folder_input.text(),
|
||||
"seven_zip_exe": self.seven_zip_input.text()
|
||||
},
|
||||
"sql": {
|
||||
"server": self.sql_server_input.text(),
|
||||
"use_windows_auth": self.sql_windows_auth_checkbox.isChecked(),
|
||||
"username": self.sql_username_input.text(),
|
||||
"password_encrypted": ""
|
||||
},
|
||||
"concurrency": {
|
||||
"extract_workers": self.extract_workers_spinbox.value(),
|
||||
"restore_workers": self.restore_workers_spinbox.value()
|
||||
},
|
||||
"stability": {
|
||||
"check_enabled": self.stability_enabled_checkbox.isChecked(),
|
||||
"check_interval_seconds": self.stability_interval_spinbox.value(),
|
||||
"stable_duration_seconds": self.stable_duration_spinbox.value(),
|
||||
"use_ready_marker": self.ready_marker_checkbox.isChecked()
|
||||
},
|
||||
"timeouts": {
|
||||
"extract_minutes": self.extract_timeout_spinbox.value(),
|
||||
"restore_minutes": self.restore_timeout_spinbox.value()
|
||||
},
|
||||
"retries": self.config.get("retries", {"max_attempts": 3, "retry_delay_seconds": 60}),
|
||||
"features": {
|
||||
"dry_run_mode": self.dry_run_checkbox.isChecked(),
|
||||
"auto_scan_enabled": self.auto_scan_checkbox.isChecked(),
|
||||
"scan_interval_seconds": self.scan_interval_spinbox.value()
|
||||
}
|
||||
}
|
||||
|
||||
# Cifrar password si no usa Windows Auth
|
||||
if not self.sql_windows_auth_checkbox.isChecked():
|
||||
password = self.sql_password_input.text()
|
||||
if password:
|
||||
try:
|
||||
config["sql"]["password_encrypted"] = encrypt_password(password)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Advertencia", f"Error cifrando contraseña: {e}")
|
||||
|
||||
# Guardar
|
||||
try:
|
||||
self.engine.save_config(config)
|
||||
QMessageBox.information(self, "Éxito", "Configuración guardada exitosamente.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error guardando configuración: {e}")
|
||||
|
||||
def _browse_folder(self, line_edit: QLineEdit):
|
||||
"""Abre el diálogo para seleccionar carpeta."""
|
||||
folder = QFileDialog.getExistingDirectory(self, "Seleccionar Carpeta")
|
||||
if folder:
|
||||
line_edit.setText(folder)
|
||||
|
||||
def _browse_seven_zip(self):
|
||||
"""Abre el diálogo para seleccionar 7z.exe."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Seleccionar 7z.exe",
|
||||
"C:\\Program Files\\7-Zip",
|
||||
"Ejecutables (*.exe)"
|
||||
)
|
||||
if file_path:
|
||||
self.seven_zip_input.setText(file_path)
|
||||
|
||||
def _detect_seven_zip(self):
|
||||
"""Auto-detecta 7-Zip."""
|
||||
path = SevenZipExtractor._auto_detect_7zip()
|
||||
if path:
|
||||
self.seven_zip_input.setText(path)
|
||||
QMessageBox.information(self, "Éxito", f"7-Zip detectado en:\n{path}")
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No Encontrado",
|
||||
"No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente."
|
||||
)
|
||||
|
||||
def _on_auth_changed(self, checked: bool):
|
||||
"""Maneja el cambio en el tipo de autenticación."""
|
||||
self.sql_username_input.setEnabled(not checked)
|
||||
self.sql_password_input.setEnabled(not checked)
|
||||
|
||||
def _test_sql_connection(self):
|
||||
"""Prueba la conexión a SQL Server."""
|
||||
server = self.sql_server_input.text()
|
||||
use_windows_auth = self.sql_windows_auth_checkbox.isChecked()
|
||||
username = self.sql_username_input.text()
|
||||
password = self.sql_password_input.text()
|
||||
|
||||
if not server:
|
||||
QMessageBox.warning(self, "Error", "Debe especificar un servidor.")
|
||||
return
|
||||
|
||||
try:
|
||||
manager = SQLServerManager(
|
||||
server=server,
|
||||
use_windows_auth=use_windows_auth,
|
||||
username=username if not use_windows_auth else None,
|
||||
password=password if not use_windows_auth else None
|
||||
)
|
||||
|
||||
success, error = manager.test_connection()
|
||||
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Éxito",
|
||||
"Conexión a SQL Server exitosa."
|
||||
)
|
||||
else:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Error",
|
||||
f"Error conectando a SQL Server:\n{error}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error: {e}")
|
||||
195
app/ui/dashboard_tab.py
Normal file
195
app/ui/dashboard_tab.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Tab del Dashboard."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox,
|
||||
QTableWidget, QTableWidgetItem, QPushButton, QGridLayout
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ..db.job_repository import JobRepository
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
"""Tab del Dashboard con estadísticas y estado."""
|
||||
|
||||
def __init__(self, engine):
|
||||
"""Inicializa el tab."""
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self._setup_ui()
|
||||
self._load_initial_stats()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Título
|
||||
title = QLabel("Dashboard - Estado del Sistema")
|
||||
title_font = QFont()
|
||||
title_font.setPointSize(16)
|
||||
title_font.setBold(True)
|
||||
title.setFont(title_font)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Grid de estadísticas
|
||||
stats_group = QGroupBox("Estadísticas de Jobs")
|
||||
stats_layout = QGridLayout()
|
||||
|
||||
self.stats_labels = {}
|
||||
stats_items = [
|
||||
("total", "Total de Jobs", 0, 0),
|
||||
("queued", "En Cola", 0, 1),
|
||||
("running", "Ejecutando", 0, 2),
|
||||
("completed", "Completados", 1, 0),
|
||||
("failed", "Fallados", 1, 1),
|
||||
("active_threads", "Threads Activos", 1, 2)
|
||||
]
|
||||
|
||||
for key, label_text, row, col in stats_items:
|
||||
label = QLabel(label_text + ":")
|
||||
value = QLabel("0")
|
||||
value_font = QFont()
|
||||
value_font.setPointSize(18)
|
||||
value_font.setBold(True)
|
||||
value.setFont(value_font)
|
||||
value.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
stats_layout.addWidget(label, row * 2, col)
|
||||
stats_layout.addWidget(value, row * 2 + 1, col)
|
||||
|
||||
self.stats_labels[key] = value
|
||||
|
||||
stats_group.setLayout(stats_layout)
|
||||
layout.addWidget(stats_group)
|
||||
|
||||
# Tiempos promedio
|
||||
times_group = QGroupBox("Tiempos Promedio (completados)")
|
||||
times_layout = QHBoxLayout()
|
||||
|
||||
self.time_labels = {}
|
||||
time_items = [
|
||||
("total_ms", "Total"),
|
||||
("extract_ms", "Extracción"),
|
||||
("restore_ms", "Restauración")
|
||||
]
|
||||
|
||||
for key, label_text in time_items:
|
||||
vbox = QVBoxLayout()
|
||||
label = QLabel(label_text + ":")
|
||||
value = QLabel("0 s")
|
||||
value_font = QFont()
|
||||
value_font.setPointSize(14)
|
||||
value_font.setBold(True)
|
||||
value.setFont(value_font)
|
||||
value.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
vbox.addWidget(label)
|
||||
vbox.addWidget(value)
|
||||
times_layout.addLayout(vbox)
|
||||
|
||||
self.time_labels[key] = value
|
||||
|
||||
times_group.setLayout(times_layout)
|
||||
layout.addWidget(times_group)
|
||||
|
||||
# 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()
|
||||
|
||||
motor_group.setLayout(motor_layout)
|
||||
layout.addWidget(motor_group)
|
||||
|
||||
# Últimos jobs
|
||||
recent_group = QGroupBox("Últimos 10 Jobs")
|
||||
recent_layout = QVBoxLayout()
|
||||
|
||||
self.recent_table = QTableWidget()
|
||||
self.recent_table.setColumnCount(5)
|
||||
self.recent_table.setHorizontalHeaderLabels([
|
||||
"Job ID", "Archivo", "Nodo", "DB", "Estado"
|
||||
])
|
||||
self.recent_table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
recent_layout.addWidget(self.recent_table)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Actualizar")
|
||||
refresh_btn.clicked.connect(self._refresh_recent_jobs)
|
||||
recent_layout.addWidget(refresh_btn)
|
||||
|
||||
recent_group.setLayout(recent_layout)
|
||||
layout.addWidget(recent_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def _load_initial_stats(self):
|
||||
"""Carga las estadísticas iniciales."""
|
||||
stats = self.engine.get_stats()
|
||||
self.update_stats(stats)
|
||||
self._refresh_recent_jobs()
|
||||
|
||||
def update_stats(self, stats: dict):
|
||||
"""Actualiza las estadísticas en la UI."""
|
||||
# Contadores
|
||||
for key in ["total", "queued", "running", "completed", "failed", "active_threads"]:
|
||||
if key in stats:
|
||||
self.stats_labels[key].setText(str(stats[key]))
|
||||
|
||||
# Tiempos
|
||||
for key in ["total_ms", "extract_ms", "restore_ms"]:
|
||||
if key in stats:
|
||||
ms = stats[key]
|
||||
seconds = ms / 1000
|
||||
if seconds >= 60:
|
||||
minutes = seconds / 60
|
||||
text = f"{minutes:.1f} min"
|
||||
else:
|
||||
text = f"{seconds:.1f} s"
|
||||
self.time_labels[key].setText(text)
|
||||
|
||||
# Estado del motor
|
||||
if stats.get("running"):
|
||||
if stats.get("paused"):
|
||||
self.motor_status_label.setText("Pausado")
|
||||
self.motor_status_label.setStyleSheet("color: orange;")
|
||||
else:
|
||||
self.motor_status_label.setText("Corriendo")
|
||||
self.motor_status_label.setStyleSheet("color: green;")
|
||||
else:
|
||||
self.motor_status_label.setText("Detenido")
|
||||
self.motor_status_label.setStyleSheet("color: red;")
|
||||
|
||||
def _refresh_recent_jobs(self):
|
||||
"""Refresca la tabla de jobs recientes."""
|
||||
jobs = JobRepository.get_all(limit=10)
|
||||
|
||||
self.recent_table.setRowCount(len(jobs))
|
||||
|
||||
for i, job in enumerate(jobs):
|
||||
self.recent_table.setItem(i, 0, QTableWidgetItem(job.job_id[:8]))
|
||||
self.recent_table.setItem(i, 1, QTableWidgetItem(job.source_name))
|
||||
self.recent_table.setItem(i, 2, QTableWidgetItem(job.node_name or "N/A"))
|
||||
self.recent_table.setItem(i, 3, QTableWidgetItem(job.db_name or "N/A"))
|
||||
|
||||
status_item = QTableWidgetItem(job.status)
|
||||
# Colorear según estado
|
||||
if job.status == "completed":
|
||||
status_item.setForeground(Qt.GlobalColor.darkGreen)
|
||||
elif "failed" in job.status:
|
||||
status_item.setForeground(Qt.GlobalColor.red)
|
||||
|
||||
self.recent_table.setItem(i, 4, status_item)
|
||||
|
||||
self.recent_table.resizeColumnsToContents()
|
||||
228
app/ui/jobs_tab.py
Normal file
228
app/ui/jobs_tab.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Tab de Jobs."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
|
||||
QTableWidgetItem, QPushButton, QLineEdit, QLabel,
|
||||
QComboBox, QSplitter, QTextEdit, QGroupBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ..db.job_repository import JobRepository
|
||||
from ..db.job_step_repository import JobStepRepository
|
||||
from ..constants import JobStatus
|
||||
|
||||
|
||||
class JobsTab(QWidget):
|
||||
"""Tab de gestión y visualización de jobs."""
|
||||
|
||||
def __init__(self, engine):
|
||||
"""Inicializa el tab."""
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self.selected_job_id = None
|
||||
self._setup_ui()
|
||||
self.refresh_jobs()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Filtros
|
||||
filters_layout = QHBoxLayout()
|
||||
|
||||
filters_layout.addWidget(QLabel("Buscar:"))
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Nombre de archivo o Job ID...")
|
||||
self.search_input.textChanged.connect(self.refresh_jobs)
|
||||
filters_layout.addWidget(self.search_input)
|
||||
|
||||
filters_layout.addWidget(QLabel("Estado:"))
|
||||
self.status_filter = QComboBox()
|
||||
self.status_filter.addItems([
|
||||
"Todos",
|
||||
JobStatus.QUEUED,
|
||||
JobStatus.EXTRACTING,
|
||||
JobStatus.RESTORING,
|
||||
JobStatus.COMPLETED,
|
||||
JobStatus.FAILED
|
||||
])
|
||||
self.status_filter.currentTextChanged.connect(self.refresh_jobs)
|
||||
filters_layout.addWidget(self.status_filter)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Actualizar")
|
||||
refresh_btn.clicked.connect(self.refresh_jobs)
|
||||
filters_layout.addWidget(refresh_btn)
|
||||
|
||||
filters_layout.addStretch()
|
||||
|
||||
layout.addLayout(filters_layout)
|
||||
|
||||
# Splitter para tabla y detalles
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# Tabla de jobs
|
||||
self.jobs_table = QTableWidget()
|
||||
self.jobs_table.setColumnCount(8)
|
||||
self.jobs_table.setHorizontalHeaderLabels([
|
||||
"Job ID", "Fecha", "Archivo", "Nodo", "DB", "Estado", "Intentos", "Tiempo (s)"
|
||||
])
|
||||
self.jobs_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.jobs_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.jobs_table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
|
||||
splitter.addWidget(self.jobs_table)
|
||||
|
||||
# Detalles del job seleccionado
|
||||
details_widget = QWidget()
|
||||
details_layout = QVBoxLayout(details_widget)
|
||||
|
||||
details_group = QGroupBox("Detalles del Job")
|
||||
details_group_layout = QVBoxLayout()
|
||||
|
||||
self.job_details_text = QTextEdit()
|
||||
self.job_details_text.setReadOnly(True)
|
||||
self.job_details_text.setMaximumHeight(200)
|
||||
details_group_layout.addWidget(self.job_details_text)
|
||||
|
||||
details_group.setLayout(details_group_layout)
|
||||
details_layout.addWidget(details_group)
|
||||
|
||||
steps_group = QGroupBox("Pasos del Job")
|
||||
steps_group_layout = QVBoxLayout()
|
||||
|
||||
self.steps_table = QTableWidget()
|
||||
self.steps_table.setColumnCount(5)
|
||||
self.steps_table.setHorizontalHeaderLabels([
|
||||
"Paso", "Inicio", "Duración (ms)", "Exit Code", "Error"
|
||||
])
|
||||
steps_group_layout.addWidget(self.steps_table)
|
||||
|
||||
steps_group.setLayout(steps_group_layout)
|
||||
details_layout.addWidget(steps_group)
|
||||
|
||||
splitter.addWidget(details_widget)
|
||||
|
||||
splitter.setStretchFactor(0, 3)
|
||||
splitter.setStretchFactor(1, 2)
|
||||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
def refresh_jobs(self):
|
||||
"""Refresca la lista de jobs."""
|
||||
# Obtener filtros
|
||||
search_text = self.search_input.text().lower()
|
||||
status_filter = self.status_filter.currentText()
|
||||
|
||||
# Obtener jobs
|
||||
if status_filter == "Todos":
|
||||
jobs = JobRepository.get_all(limit=100)
|
||||
else:
|
||||
jobs = JobRepository.get_all(status=status_filter, limit=100)
|
||||
|
||||
# Filtrar por búsqueda
|
||||
if search_text:
|
||||
jobs = [
|
||||
j for j in jobs
|
||||
if search_text in j.job_id.lower() or
|
||||
search_text in j.source_name.lower()
|
||||
]
|
||||
|
||||
# Llenar tabla
|
||||
self.jobs_table.setRowCount(len(jobs))
|
||||
|
||||
for i, job in enumerate(jobs):
|
||||
self.jobs_table.setItem(i, 0, QTableWidgetItem(job.job_id[:8]))
|
||||
|
||||
# Fecha
|
||||
created = job.created_at.split("T")[0] if "T" in job.created_at else job.created_at
|
||||
self.jobs_table.setItem(i, 1, QTableWidgetItem(created))
|
||||
|
||||
self.jobs_table.setItem(i, 2, QTableWidgetItem(job.source_name))
|
||||
self.jobs_table.setItem(i, 3, QTableWidgetItem(job.node_name or "N/A"))
|
||||
self.jobs_table.setItem(i, 4, QTableWidgetItem(job.db_name or "N/A"))
|
||||
|
||||
status_item = QTableWidgetItem(job.status)
|
||||
if job.status == JobStatus.COMPLETED:
|
||||
status_item.setForeground(Qt.GlobalColor.darkGreen)
|
||||
elif "failed" in job.status:
|
||||
status_item.setForeground(Qt.GlobalColor.red)
|
||||
self.jobs_table.setItem(i, 5, status_item)
|
||||
|
||||
self.jobs_table.setItem(i, 6, QTableWidgetItem(str(job.attempts)))
|
||||
|
||||
# Tiempo
|
||||
if job.total_ms:
|
||||
time_s = job.total_ms / 1000
|
||||
self.jobs_table.setItem(i, 7, QTableWidgetItem(f"{time_s:.1f}"))
|
||||
else:
|
||||
self.jobs_table.setItem(i, 7, QTableWidgetItem("N/A"))
|
||||
|
||||
# Guardar job_id en la fila
|
||||
self.jobs_table.item(i, 0).setData(Qt.ItemDataRole.UserRole, job.job_id)
|
||||
|
||||
self.jobs_table.resizeColumnsToContents()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Maneja el cambio de selección en la tabla."""
|
||||
selected_items = self.jobs_table.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
# Obtener job_id de la fila seleccionada
|
||||
row = selected_items[0].row()
|
||||
job_id = self.jobs_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
if job_id != self.selected_job_id:
|
||||
self.selected_job_id = job_id
|
||||
self._load_job_details()
|
||||
|
||||
def _load_job_details(self):
|
||||
"""Carga los detalles del job seleccionado."""
|
||||
if not self.selected_job_id:
|
||||
return
|
||||
|
||||
job = JobRepository.get(self.selected_job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
# Mostrar detalles
|
||||
details = f"""
|
||||
Job ID: {job.job_id}
|
||||
Archivo: {job.source_name}
|
||||
Ruta: {job.source_path}
|
||||
Nodo: {job.node_name or 'N/A'}
|
||||
Base de Datos: {job.db_name or 'N/A'}
|
||||
Estado: {job.status}
|
||||
Intentos: {job.attempts}
|
||||
|
||||
Creado: {job.created_at}
|
||||
Iniciado: {job.started_at or 'N/A'}
|
||||
Finalizado: {job.finished_at or 'N/A'}
|
||||
|
||||
Tiempos:
|
||||
- Total: {job.total_ms or 0} ms
|
||||
- Extracción: {job.extract_ms or 0} ms
|
||||
- Filelist: {job.filelist_ms or 0} ms
|
||||
- Restauración: {job.restore_ms or 0} ms
|
||||
|
||||
Último Error: {job.last_error or 'N/A'}
|
||||
""".strip()
|
||||
|
||||
self.job_details_text.setPlainText(details)
|
||||
|
||||
# Cargar steps
|
||||
steps = JobStepRepository.get_by_job(self.selected_job_id)
|
||||
|
||||
self.steps_table.setRowCount(len(steps))
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
self.steps_table.setItem(i, 0, QTableWidgetItem(step.step))
|
||||
|
||||
started = step.started_at.split("T")[1].split(".")[0] if "T" in step.started_at else step.started_at
|
||||
self.steps_table.setItem(i, 1, QTableWidgetItem(started))
|
||||
|
||||
self.steps_table.setItem(i, 2, QTableWidgetItem(str(step.duration_ms or 0)))
|
||||
self.steps_table.setItem(i, 3, QTableWidgetItem(str(step.exit_code or "")))
|
||||
self.steps_table.setItem(i, 4, QTableWidgetItem(step.error or ""))
|
||||
|
||||
self.steps_table.resizeColumnsToContents()
|
||||
105
app/ui/logs_tab.py
Normal file
105
app/ui/logs_tab.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tab de logs y soporte."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QTextEdit, QComboBox, QLabel, QMessageBox
|
||||
)
|
||||
|
||||
from ..db.event_repository import EventRepository
|
||||
from ..constants import LOGS_DIR, LogLevel
|
||||
|
||||
|
||||
class LogsTab(QWidget):
|
||||
"""Tab de visualización de logs y soporte."""
|
||||
|
||||
def __init__(self, engine):
|
||||
"""Inicializa el tab."""
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self._setup_ui()
|
||||
self.refresh_logs()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Controles
|
||||
controls_layout = QHBoxLayout()
|
||||
|
||||
controls_layout.addWidget(QLabel("Nivel:"))
|
||||
|
||||
self.level_filter = QComboBox()
|
||||
self.level_filter.addItems([
|
||||
"Todos",
|
||||
LogLevel.DEBUG,
|
||||
LogLevel.INFO,
|
||||
LogLevel.WARNING,
|
||||
LogLevel.ERROR,
|
||||
LogLevel.CRITICAL
|
||||
])
|
||||
self.level_filter.setCurrentText(LogLevel.INFO)
|
||||
self.level_filter.currentTextChanged.connect(self.refresh_logs)
|
||||
controls_layout.addWidget(self.level_filter)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Actualizar")
|
||||
refresh_btn.clicked.connect(self.refresh_logs)
|
||||
controls_layout.addWidget(refresh_btn)
|
||||
|
||||
controls_layout.addStretch()
|
||||
|
||||
open_folder_btn = QPushButton("📁 Abrir Carpeta Logs")
|
||||
open_folder_btn.clicked.connect(self._open_logs_folder)
|
||||
controls_layout.addWidget(open_folder_btn)
|
||||
|
||||
layout.addLayout(controls_layout)
|
||||
|
||||
# Área de logs
|
||||
self.logs_text = QTextEdit()
|
||||
self.logs_text.setReadOnly(True)
|
||||
self.logs_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
||||
layout.addWidget(self.logs_text)
|
||||
|
||||
# Info
|
||||
info_layout = QHBoxLayout()
|
||||
info_label = QLabel("💡 Los logs se rotan automáticamente cada 10MB.")
|
||||
info_layout.addWidget(info_label)
|
||||
info_layout.addStretch()
|
||||
|
||||
layout.addLayout(info_layout)
|
||||
|
||||
def refresh_logs(self):
|
||||
"""Refresca los logs desde la base de datos."""
|
||||
level = self.level_filter.currentText()
|
||||
|
||||
if level == "Todos":
|
||||
events = EventRepository.get_all(limit=200)
|
||||
else:
|
||||
events = EventRepository.get_all(level=level, limit=200)
|
||||
|
||||
# Formatear logs
|
||||
log_lines = []
|
||||
for event in reversed(events): # Mostrar más recientes primero
|
||||
timestamp = event.created_at.replace("T", " ").split(".")[0]
|
||||
job_info = f" [Job: {event.job_id[:8]}]" if event.job_id else ""
|
||||
log_lines.append(
|
||||
f"{timestamp} | {event.level:8s}{job_info} | {event.message}"
|
||||
)
|
||||
|
||||
self.logs_text.setPlainText("\n".join(log_lines))
|
||||
|
||||
def _open_logs_folder(self):
|
||||
"""Abre la carpeta de logs en el explorador."""
|
||||
try:
|
||||
if LOGS_DIR.exists():
|
||||
subprocess.run(["explorer", str(LOGS_DIR)])
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Carpeta No Existe",
|
||||
f"La carpeta de logs no existe:\n{LOGS_DIR}"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error abriendo carpeta: {e}")
|
||||
267
app/ui/main_window.py
Normal file
267
app/ui/main_window.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Ventana principal de la aplicación."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
|
||||
QSystemTrayIcon, QMenu
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QIcon, 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 ..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):
|
||||
"""Inicializa la ventana principal."""
|
||||
super().__init__()
|
||||
|
||||
# Motor de restauración
|
||||
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._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
|
||||
|
||||
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
|
||||
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)
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
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)
|
||||
|
||||
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():
|
||||
self.hide()
|
||||
event.ignore()
|
||||
app_logger.info("Ventana minimizada a bandeja del sistema")
|
||||
else:
|
||||
event.accept()
|
||||
|
||||
def _on_tray_activated(self, reason):
|
||||
"""Maneja la activación del icono de tray."""
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
if self.isVisible():
|
||||
self.hide()
|
||||
else:
|
||||
self._show_window()
|
||||
|
||||
def _show_window(self):
|
||||
"""Muestra la ventana."""
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
app_logger.info("Motor reanudado desde UI")
|
||||
|
||||
def _stop_engine(self):
|
||||
"""Detiene el motor."""
|
||||
self.engine.stop()
|
||||
self.stop_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(False)
|
||||
self.start_action.setEnabled(True)
|
||||
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."""
|
||||
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"
|
||||
)
|
||||
|
||||
def _quit_application(self):
|
||||
"""Sale de la aplicación completamente."""
|
||||
app_logger.info("Saliendo de la aplicación")
|
||||
self.engine.stop()
|
||||
self.tray_icon.hide()
|
||||
self.close()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.quit()
|
||||
257
app/ui/nodes_tab.py
Normal file
257
app/ui/nodes_tab.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Tab de gestión de nodos."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
|
||||
QTableWidgetItem, QPushButton, QDialog, QFormLayout,
|
||||
QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox,
|
||||
QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ..db.node_repository import NodeRepository
|
||||
|
||||
|
||||
class NodeDialog(QDialog):
|
||||
"""Diálogo para crear/editar nodos."""
|
||||
|
||||
def __init__(self, parent=None, node=None):
|
||||
"""Inicializa el diálogo."""
|
||||
super().__init__(parent)
|
||||
|
||||
self.node = node
|
||||
self.setWindowTitle("Editar Nodo" if node else "Nuevo Nodo")
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
if node:
|
||||
self._load_node_data()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
layout = QFormLayout(self)
|
||||
|
||||
self.node_name_input = QLineEdit()
|
||||
self.node_name_input.setPlaceholderText("Ej: BACKUP_SISTEMA.ZIP")
|
||||
layout.addRow("Nombre del Nodo:", self.node_name_input)
|
||||
|
||||
self.db_name_input = QLineEdit()
|
||||
self.db_name_input.setPlaceholderText("Ej: Sistema_DB")
|
||||
layout.addRow("Base de Datos:", self.db_name_input)
|
||||
|
||||
self.active_checkbox = QCheckBox()
|
||||
self.active_checkbox.setChecked(True)
|
||||
layout.addRow("Activo:", self.active_checkbox)
|
||||
|
||||
self.notes_input = QTextEdit()
|
||||
self.notes_input.setPlaceholderText("Notas opcionales...")
|
||||
self.notes_input.setMaximumHeight(100)
|
||||
layout.addRow("Notas:", self.notes_input)
|
||||
|
||||
# Botones
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addRow(buttons)
|
||||
|
||||
def _load_node_data(self):
|
||||
"""Carga los datos del nodo a editar."""
|
||||
self.node_name_input.setText(self.node.node_name)
|
||||
self.node_name_input.setEnabled(False) # No permitir cambiar el nombre
|
||||
self.db_name_input.setText(self.node.db_name)
|
||||
self.active_checkbox.setChecked(self.node.active)
|
||||
self.notes_input.setPlainText(self.node.notes or "")
|
||||
|
||||
def get_data(self):
|
||||
"""Obtiene los datos del formulario."""
|
||||
return {
|
||||
"node_name": self.node_name_input.text().strip(),
|
||||
"db_name": self.db_name_input.text().strip(),
|
||||
"active": self.active_checkbox.isChecked(),
|
||||
"notes": self.notes_input.toPlainText().strip()
|
||||
}
|
||||
|
||||
|
||||
class NodesTab(QWidget):
|
||||
"""Tab de gestión de nodos."""
|
||||
|
||||
def __init__(self, engine):
|
||||
"""Inicializa el tab."""
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self._setup_ui()
|
||||
self.refresh_nodes()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Botones de acción
|
||||
actions_layout = QHBoxLayout()
|
||||
|
||||
self.add_btn = QPushButton("➕ Nuevo Nodo")
|
||||
self.add_btn.clicked.connect(self._add_node)
|
||||
actions_layout.addWidget(self.add_btn)
|
||||
|
||||
self.edit_btn = QPushButton("✏️ Editar")
|
||||
self.edit_btn.clicked.connect(self._edit_node)
|
||||
self.edit_btn.setEnabled(False)
|
||||
actions_layout.addWidget(self.edit_btn)
|
||||
|
||||
self.delete_btn = QPushButton("🗑️ Eliminar")
|
||||
self.delete_btn.clicked.connect(self._delete_node)
|
||||
self.delete_btn.setEnabled(False)
|
||||
actions_layout.addWidget(self.delete_btn)
|
||||
|
||||
actions_layout.addStretch()
|
||||
|
||||
refresh_btn = QPushButton("🔄 Actualizar")
|
||||
refresh_btn.clicked.connect(self.refresh_nodes)
|
||||
actions_layout.addWidget(refresh_btn)
|
||||
|
||||
layout.addLayout(actions_layout)
|
||||
|
||||
# Tabla de nodos
|
||||
self.nodes_table = QTableWidget()
|
||||
self.nodes_table.setColumnCount(4)
|
||||
self.nodes_table.setHorizontalHeaderLabels([
|
||||
"Nombre del Nodo", "Base de Datos", "Activo", "Notas"
|
||||
])
|
||||
self.nodes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.nodes_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.nodes_table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
self.nodes_table.itemDoubleClicked.connect(self._edit_node)
|
||||
|
||||
layout.addWidget(self.nodes_table)
|
||||
|
||||
# Info
|
||||
info_label = QLabel(
|
||||
"💡 El nombre del nodo debe coincidir con el nombre del archivo ZIP (con extensión). "
|
||||
"Se compara en MAYÚSCULAS y case-insensitive."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("color: #666; font-size: 10pt;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
def refresh_nodes(self):
|
||||
"""Refresca la lista de nodos."""
|
||||
nodes = NodeRepository.get_all()
|
||||
|
||||
self.nodes_table.setRowCount(len(nodes))
|
||||
|
||||
for i, node in enumerate(nodes):
|
||||
self.nodes_table.setItem(i, 0, QTableWidgetItem(node.node_name))
|
||||
self.nodes_table.setItem(i, 1, QTableWidgetItem(node.db_name))
|
||||
|
||||
active_item = QTableWidgetItem("✓" if node.active else "✗")
|
||||
active_item.setForeground(
|
||||
Qt.GlobalColor.darkGreen if node.active else Qt.GlobalColor.red
|
||||
)
|
||||
self.nodes_table.setItem(i, 2, active_item)
|
||||
|
||||
self.nodes_table.setItem(i, 3, QTableWidgetItem(node.notes or ""))
|
||||
|
||||
# Guardar node_name en la fila
|
||||
self.nodes_table.item(i, 0).setData(Qt.ItemDataRole.UserRole, node.node_name)
|
||||
|
||||
self.nodes_table.resizeColumnsToContents()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Maneja el cambio de selección."""
|
||||
has_selection = len(self.nodes_table.selectedItems()) > 0
|
||||
self.edit_btn.setEnabled(has_selection)
|
||||
self.delete_btn.setEnabled(has_selection)
|
||||
|
||||
def _add_node(self):
|
||||
"""Abre el diálogo para agregar un nodo."""
|
||||
dialog = NodeDialog(self)
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
data = dialog.get_data()
|
||||
|
||||
if not data["node_name"] or not data["db_name"]:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"El nombre del nodo y la base de datos son obligatorios."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
NodeRepository.create(
|
||||
node_name=data["node_name"],
|
||||
db_name=data["db_name"],
|
||||
notes=data["notes"]
|
||||
)
|
||||
|
||||
if not data["active"]:
|
||||
NodeRepository.update(data["node_name"], active=False)
|
||||
|
||||
self.refresh_nodes()
|
||||
QMessageBox.information(self, "Éxito", "Nodo creado exitosamente.")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error creando nodo: {e}")
|
||||
|
||||
def _edit_node(self):
|
||||
"""Abre el diálogo para editar un nodo."""
|
||||
selected_items = self.nodes_table.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
row = selected_items[0].row()
|
||||
node_name = self.nodes_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
node = NodeRepository.get(node_name)
|
||||
if not node:
|
||||
return
|
||||
|
||||
dialog = NodeDialog(self, node)
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
data = dialog.get_data()
|
||||
|
||||
if not data["db_name"]:
|
||||
QMessageBox.warning(self, "Error", "La base de datos es obligatoria.")
|
||||
return
|
||||
|
||||
try:
|
||||
NodeRepository.update(
|
||||
node_name=node_name,
|
||||
db_name=data["db_name"],
|
||||
active=data["active"],
|
||||
notes=data["notes"]
|
||||
)
|
||||
|
||||
self.refresh_nodes()
|
||||
QMessageBox.information(self, "Éxito", "Nodo actualizado exitosamente.")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error actualizando nodo: {e}")
|
||||
|
||||
def _delete_node(self):
|
||||
"""Elimina el nodo seleccionado."""
|
||||
selected_items = self.nodes_table.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
row = selected_items[0].row()
|
||||
node_name = self.nodes_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirmar Eliminación",
|
||||
f"¿Está seguro de eliminar el nodo '{node_name}'?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
NodeRepository.delete(node_name)
|
||||
self.refresh_nodes()
|
||||
QMessageBox.information(self, "Éxito", "Nodo eliminado exitosamente.")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error eliminando nodo: {e}")
|
||||
Reference in New Issue
Block a user