Initial commit: CloudRestoreAS v1.0.0 - Aplicación completa de restauración automática SQL Server

This commit is contained in:
2026-01-25 16:53:00 -07:00
commit 854ffa116f
43 changed files with 6357 additions and 0 deletions

1
app/ui/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Interfaz gráfica de usuario."""

399
app/ui/config_tab.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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}")