818 lines
30 KiB
Python
818 lines
30 KiB
Python
import os
|
||
import sys
|
||
import time
|
||
import socket
|
||
import platform
|
||
import threading
|
||
from pathlib import Path
|
||
from typing import Optional, List
|
||
import httpx
|
||
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
||
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QTabWidget, QLabel, QPushButton, QLineEdit, QTableWidget,
|
||
QTableWidgetItem, QHeaderView, QProgressBar, QFileDialog,
|
||
QMessageBox, QSystemTrayIcon, QMenu, QDialog, QFormLayout,
|
||
QComboBox, QSpinBox, QFrame, QCheckBox
|
||
)
|
||
|
||
# Add agent root to sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
from agent.config import load_config, save_config, AgentConfig, LocalFolderJob
|
||
from agent.service import AgentDaemon
|
||
from agent.uploader import ChunkUploader
|
||
from agent.chunker import compute_file_sha256
|
||
from agent.state_db import state_db
|
||
from create_icons import generate_app_icons
|
||
|
||
# --- Modern Dark QSS Stylesheet ---
|
||
DARK_QSS = """
|
||
QMainWindow, QWidget {
|
||
background-color: #0B0F19;
|
||
color: #F8FAFC;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-size: 13px;
|
||
}
|
||
|
||
QTabWidget::pane {
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
background-color: #111827;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
QTabBar::tab {
|
||
background: #1E293B;
|
||
color: #94A3B8;
|
||
padding: 10px 20px;
|
||
margin-right: 4px;
|
||
border-top-left-radius: 6px;
|
||
border-top-right-radius: 6px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
QTabBar::tab:selected {
|
||
background: #06B6D4;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
QTabBar::tab:hover:!selected {
|
||
background: #334155;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
QFrame.card {
|
||
background-color: #1E293B;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
}
|
||
|
||
QLineEdit, QComboBox, QSpinBox {
|
||
background-color: #0B0F19;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
color: #FFFFFF;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
QLineEdit:focus, QComboBox:focus, QSpinBox:focus {
|
||
border: 1px solid #06B6D4;
|
||
}
|
||
|
||
QCheckBox {
|
||
color: #E2E8F0;
|
||
font-size: 13px;
|
||
spacing: 8px;
|
||
}
|
||
|
||
QCheckBox::indicator {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 4px;
|
||
border: 1px solid #475569;
|
||
background-color: #0B0F19;
|
||
}
|
||
|
||
QCheckBox::indicator:checked {
|
||
background-color: #06B6D4;
|
||
border-color: #06B6D4;
|
||
}
|
||
|
||
QPushButton {
|
||
background-color: #334155;
|
||
color: #FFFFFF;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 9px 18px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
QPushButton:hover {
|
||
background-color: #475569;
|
||
}
|
||
|
||
QPushButton.primary {
|
||
background-color: #06B6D4;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
QPushButton.primary:hover {
|
||
background-color: #0891B2;
|
||
}
|
||
|
||
QPushButton.success {
|
||
background-color: #10B981;
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
QPushButton.success:hover {
|
||
background-color: #059669;
|
||
}
|
||
|
||
QPushButton.danger {
|
||
background-color: rgba(244, 63, 94, 0.2);
|
||
color: #FDA4AF;
|
||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||
}
|
||
|
||
QPushButton.danger:hover {
|
||
background-color: rgba(244, 63, 94, 0.35);
|
||
}
|
||
|
||
QProgressBar {
|
||
background-color: #1E293B;
|
||
border: 1px solid #334155;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
color: #FFFFFF;
|
||
font-weight: bold;
|
||
height: 18px;
|
||
}
|
||
|
||
QProgressBar::chunk {
|
||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06B6D4, stop:1 #6366F1);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
QTableWidget {
|
||
background-color: #0B0F19;
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
border-radius: 6px;
|
||
gridline-color: rgba(255, 255, 255, 0.04);
|
||
}
|
||
|
||
QTableWidget::item {
|
||
padding: 8px;
|
||
color: #F8FAFC;
|
||
}
|
||
|
||
QTableWidget::item:selected {
|
||
background-color: rgba(6, 182, 212, 0.2);
|
||
}
|
||
|
||
QHeaderView::section {
|
||
background-color: #1E293B;
|
||
color: #94A3B8;
|
||
padding: 8px;
|
||
font-weight: bold;
|
||
border: none;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
|
||
QMenu {
|
||
background-color: #1E293B;
|
||
color: #FFFFFF;
|
||
border: 1px solid #334155;
|
||
}
|
||
|
||
QMenu::item:selected {
|
||
background-color: #06B6D4;
|
||
}
|
||
"""
|
||
|
||
class WorkerSignals(QThread):
|
||
started_signal = pyqtSignal(str, int)
|
||
progress_signal = pyqtSignal(str, int, int, float)
|
||
completed_signal = pyqtSignal(str, str, int)
|
||
error_signal = pyqtSignal(str, str)
|
||
status_signal = pyqtSignal(str, str)
|
||
|
||
class AddFolderDialog(QDialog):
|
||
"""Dialog for selecting and configuring a Windows backup folder."""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
|
||
self.resize(500, 320)
|
||
self.setStyleSheet(DARK_QSS)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(14)
|
||
|
||
form = QFormLayout()
|
||
form.setSpacing(12)
|
||
|
||
self.txt_name = QLineEdit()
|
||
self.txt_name.setPlaceholderText("Ej: Base de Datos SQL Producción")
|
||
form.addRow("Nombre descriptivo:", self.txt_name)
|
||
|
||
path_layout = QHBoxLayout()
|
||
self.txt_path = QLineEdit()
|
||
self.txt_path.setPlaceholderText("C:\\SQLBackups")
|
||
btn_browse = QPushButton("Explorar...")
|
||
btn_browse.clicked.connect(self._browse_folder)
|
||
path_layout.addWidget(self.txt_path)
|
||
path_layout.addWidget(btn_browse)
|
||
form.addRow("Ruta en Windows:", path_layout)
|
||
|
||
self.txt_patterns = QLineEdit()
|
||
self.txt_patterns.setText("*.bak,*.mdf")
|
||
form.addRow("Filtros de archivo:", self.txt_patterns)
|
||
|
||
self.spin_interval = QSpinBox()
|
||
self.spin_interval.setRange(5, 1440)
|
||
self.spin_interval.setValue(60)
|
||
self.spin_interval.setSuffix(" min")
|
||
form.addRow("Frecuencia de sondeo:", self.spin_interval)
|
||
|
||
self.spin_stable = QSpinBox()
|
||
self.spin_stable.setRange(10, 600)
|
||
self.spin_stable.setValue(60)
|
||
self.spin_stable.setSuffix(" seg")
|
||
form.addRow("Estabilidad de archivo (Locks):", self.spin_stable)
|
||
|
||
layout.addLayout(form)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
btn_layout.addStretch()
|
||
btn_cancel = QPushButton("Cancelar")
|
||
btn_cancel.clicked.connect(self.reject)
|
||
btn_save = QPushButton("Guardar Carpeta")
|
||
btn_save.setProperty("class", "primary")
|
||
btn_save.clicked.connect(self._validate_and_accept)
|
||
|
||
btn_layout.addWidget(btn_cancel)
|
||
btn_layout.addWidget(btn_save)
|
||
layout.addLayout(btn_layout)
|
||
|
||
def _browse_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(self, "Seleccionar carpeta para backup")
|
||
if folder:
|
||
self.txt_path.setText(folder)
|
||
if not self.txt_name.text():
|
||
self.txt_name.setText(Path(folder).name)
|
||
|
||
def _validate_and_accept(self):
|
||
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
|
||
QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")
|
||
return
|
||
if not self.txt_name.text():
|
||
self.txt_name.setText(Path(self.txt_path.text()).name)
|
||
self.accept()
|
||
|
||
def get_data(self) -> LocalFolderJob:
|
||
return LocalFolderJob(
|
||
name=self.txt_name.text().strip(),
|
||
source_path=self.txt_path.text().strip(),
|
||
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
||
schedule_interval_minutes=self.spin_interval.value(),
|
||
min_stable_seconds=self.spin_stable.value()
|
||
)
|
||
|
||
class OnEverDriveMainWindow(QMainWindow):
|
||
"""Main modern desktop GUI and tray controller for OnEver Drive Windows Agent."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("OnEver Drive — Agente de Backup Windows")
|
||
self.resize(780, 580)
|
||
self.setStyleSheet(DARK_QSS)
|
||
|
||
self.config = load_config()
|
||
self.app_icon = self._load_app_icon()
|
||
self.setWindowIcon(self.app_icon)
|
||
|
||
# Background Worker Daemon
|
||
self.signals = WorkerSignals()
|
||
self.daemon = AgentDaemon(
|
||
config=self.config,
|
||
on_started=lambda f, b: self.signals.started_signal.emit(f, b),
|
||
on_progress=lambda f, d, t, p: self.signals.progress_signal.emit(f, d, t, p),
|
||
on_completed=lambda f, s, b: self.signals.completed_signal.emit(f, s, b),
|
||
on_error=lambda f, e: self.signals.error_signal.emit(f, e),
|
||
on_status=lambda s, m: self.signals.status_signal.emit(s, m)
|
||
)
|
||
|
||
self._connect_signals()
|
||
self._init_ui()
|
||
self._init_tray()
|
||
|
||
if self.config.device_id:
|
||
self.daemon.start()
|
||
|
||
def _load_app_icon(self) -> QIcon:
|
||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||
png_path = assets_dir / "icon.png"
|
||
if not png_path.exists():
|
||
_, png_path = generate_app_icons()
|
||
return QIcon(str(png_path))
|
||
|
||
def _connect_signals(self):
|
||
self.signals.started_signal.connect(self._on_backup_started)
|
||
self.signals.progress_signal.connect(self._on_live_progress)
|
||
self.signals.completed_signal.connect(self._on_backup_completed)
|
||
self.signals.error_signal.connect(self._on_backup_error)
|
||
self.signals.status_signal.connect(self._on_daemon_status)
|
||
|
||
def _init_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
main_layout = QVBoxLayout(central)
|
||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||
main_layout.setSpacing(16)
|
||
|
||
# Header Bar
|
||
header = QHBoxLayout()
|
||
title_box = QVBoxLayout()
|
||
lbl_title = QLabel("OnEver Drive")
|
||
lbl_title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold))
|
||
lbl_sub = QLabel("Agente Empresarial de Sincronización y Backup para Windows")
|
||
lbl_sub.setStyleSheet("color: #94A3B8; font-size: 11px;")
|
||
title_box.addWidget(lbl_title)
|
||
title_box.addWidget(lbl_sub)
|
||
header.addLayout(title_box)
|
||
|
||
header.addStretch()
|
||
|
||
self.lbl_status_badge = QLabel("● Desconectado")
|
||
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||
header.addWidget(self.lbl_status_badge)
|
||
|
||
main_layout.addLayout(header)
|
||
|
||
# Tabs
|
||
self.tabs = QTabWidget()
|
||
self.tab_dashboard = QWidget()
|
||
self.tab_folders = QWidget()
|
||
self.tab_config = QWidget()
|
||
self.tab_history = QWidget()
|
||
|
||
self.tabs.addTab(self.tab_dashboard, "Dashboard")
|
||
self.tabs.addTab(self.tab_folders, "Carpetas de Backup")
|
||
self.tabs.addTab(self.tab_config, "Servidor & Config")
|
||
self.tabs.addTab(self.tab_history, "Historial de Archivos")
|
||
|
||
main_layout.addWidget(self.tabs)
|
||
|
||
self._build_tab_dashboard()
|
||
self._build_tab_folders()
|
||
self._build_tab_config()
|
||
self._build_tab_history()
|
||
|
||
self._update_header_status()
|
||
|
||
# --- TAB 1: DASHBOARD ---
|
||
def _build_tab_dashboard(self):
|
||
layout = QVBoxLayout(self.tab_dashboard)
|
||
layout.setContentsMargins(16, 16, 16, 16)
|
||
layout.setSpacing(16)
|
||
|
||
card_tel = QFrame()
|
||
card_tel.setProperty("class", "card")
|
||
tel_layout = QVBoxLayout(card_tel)
|
||
|
||
lbl_sec = QLabel("Transferencia en Vivo (Motor de Chunks)")
|
||
lbl_sec.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||
tel_layout.addWidget(lbl_sec)
|
||
|
||
self.lbl_transfer_info = QLabel("Estado: En espera de cambios en carpetas monitoreadas...")
|
||
self.lbl_transfer_info.setStyleSheet("color: #94A3B8;")
|
||
tel_layout.addWidget(self.lbl_transfer_info)
|
||
|
||
self.pbar_transfer = QProgressBar()
|
||
self.pbar_transfer.setValue(0)
|
||
tel_layout.addWidget(self.pbar_transfer)
|
||
|
||
self.lbl_chunk_details = QLabel("Chunks: 0 / 0 | Motor por Bloques de 4MB | SHA-256: —")
|
||
self.lbl_chunk_details.setStyleSheet("color: #64748B; font-family: 'Consolas', monospace; font-size: 11px;")
|
||
tel_layout.addWidget(self.lbl_chunk_details)
|
||
|
||
layout.addWidget(card_tel)
|
||
|
||
card_info = QFrame()
|
||
card_info.setProperty("class", "card")
|
||
info_layout = QVBoxLayout(card_info)
|
||
|
||
lbl_info_title = QLabel("Información del Dispositivo")
|
||
lbl_info_title.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
|
||
info_layout.addWidget(lbl_info_title)
|
||
|
||
self.lbl_dash_client = QLabel("Cliente ID: —")
|
||
self.lbl_dash_server = QLabel("Servidor Proxmox: —")
|
||
self.lbl_dash_folders = QLabel("Carpetas en Monitoreo: 0")
|
||
|
||
info_layout.addWidget(self.lbl_dash_client)
|
||
info_layout.addWidget(self.lbl_dash_server)
|
||
info_layout.addWidget(self.lbl_dash_folders)
|
||
|
||
layout.addWidget(card_info)
|
||
|
||
btn_box = QHBoxLayout()
|
||
btn_backup_all = QPushButton("▶ Iniciar Sincronización Manual Ahora")
|
||
btn_backup_all.setProperty("class", "primary")
|
||
btn_backup_all.clicked.connect(self._trigger_all_backups)
|
||
|
||
btn_box.addWidget(btn_backup_all)
|
||
layout.addLayout(btn_box)
|
||
layout.addStretch()
|
||
|
||
# --- TAB 2: CARPETAS DE BACKUP ---
|
||
def _build_tab_folders(self):
|
||
layout = QVBoxLayout(self.tab_folders)
|
||
layout.setContentsMargins(16, 16, 16, 16)
|
||
layout.setSpacing(12)
|
||
|
||
top_bar = QHBoxLayout()
|
||
lbl = QLabel("Carpetas de Windows Monitoreadas")
|
||
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||
top_bar.addWidget(lbl)
|
||
top_bar.addStretch()
|
||
|
||
btn_add = QPushButton("➕ Añadir Carpeta...")
|
||
btn_add.setProperty("class", "primary")
|
||
btn_add.clicked.connect(self._show_add_folder_dialog)
|
||
top_bar.addWidget(btn_add)
|
||
layout.addLayout(top_bar)
|
||
|
||
self.tbl_folders = QTableWidget(0, 5)
|
||
self.tbl_folders.setHorizontalHeaderLabels(["Nombre", "Ruta en Windows", "Filtros", "Intervalo", "Último Estado"])
|
||
self.tbl_folders.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
self.tbl_folders.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||
layout.addWidget(self.tbl_folders)
|
||
|
||
btn_row = QHBoxLayout()
|
||
btn_delete = QPushButton("🗑️ Eliminar Carpeta")
|
||
btn_delete.setProperty("class", "danger")
|
||
btn_delete.clicked.connect(self._delete_selected_folder)
|
||
btn_row.addWidget(btn_delete)
|
||
btn_row.addStretch()
|
||
layout.addLayout(btn_row)
|
||
|
||
self._refresh_folders_table()
|
||
|
||
# --- TAB 3: CONFIGURACIÓN & NOTIFICACIONES ---
|
||
def _build_tab_config(self):
|
||
layout = QVBoxLayout(self.tab_config)
|
||
layout.setContentsMargins(16, 16, 16, 16)
|
||
layout.setSpacing(16)
|
||
|
||
# Server Card
|
||
card_srv = QFrame()
|
||
card_srv.setProperty("class", "card")
|
||
form = QFormLayout(card_srv)
|
||
form.setSpacing(12)
|
||
|
||
lbl = QLabel("Conexión con el Servidor Central Proxmox VE")
|
||
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||
form.addRow(lbl)
|
||
|
||
self.txt_server_url = QLineEdit()
|
||
self.txt_server_url.setText(self.config.server_url)
|
||
|
||
btn_ping = QPushButton("🔍 Probar Conexión")
|
||
btn_ping.clicked.connect(self._test_server_connection)
|
||
|
||
srv_box = QHBoxLayout()
|
||
srv_box.addWidget(self.txt_server_url)
|
||
srv_box.addWidget(btn_ping)
|
||
form.addRow("URL Servidor:", srv_box)
|
||
|
||
self.txt_reg_code = QLineEdit()
|
||
self.txt_reg_code.setPlaceholderText("Código generado en la Web (ej: OED-XXXX-XXXX)")
|
||
form.addRow("Código de Registro:", self.txt_reg_code)
|
||
|
||
btn_register = QPushButton("🚀 Registrar / Re-vincular Dispositivo")
|
||
btn_register.setProperty("class", "primary")
|
||
btn_register.clicked.connect(self._register_device_api)
|
||
form.addRow("", btn_register)
|
||
|
||
layout.addWidget(card_srv)
|
||
|
||
# Notifications Preferences Card (Clean & Non-invasive)
|
||
card_notif = QFrame()
|
||
card_notif.setProperty("class", "card")
|
||
notif_layout = QVBoxLayout(card_notif)
|
||
notif_layout.setSpacing(10)
|
||
|
||
lbl_notif = QLabel("Preferencias de Notificaciones en Windows")
|
||
lbl_notif.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||
notif_layout.addWidget(lbl_notif)
|
||
|
||
self.chk_notif_main = QCheckBox("Habilitar notificaciones en el Área de Notificaciones (System Tray)")
|
||
self.chk_notif_main.setChecked(self.config.enable_notifications)
|
||
self.chk_notif_main.stateChanged.connect(self._save_notif_settings)
|
||
notif_layout.addWidget(self.chk_notif_main)
|
||
|
||
self.chk_notif_start = QCheckBox("Notificar únicamente cuando INICIA un proceso de respaldo")
|
||
self.chk_notif_start.setChecked(self.config.notify_on_start)
|
||
self.chk_notif_start.stateChanged.connect(self._save_notif_settings)
|
||
notif_layout.addWidget(self.chk_notif_start)
|
||
|
||
self.chk_notif_complete = QCheckBox("Notificar únicamente cuando FINALIZA con éxito (Confirmación SHA-256)")
|
||
self.chk_notif_complete.setChecked(self.config.notify_on_complete)
|
||
self.chk_notif_complete.stateChanged.connect(self._save_notif_settings)
|
||
notif_layout.addWidget(self.chk_notif_complete)
|
||
|
||
self.chk_notif_error = QCheckBox("Notificar en caso de error o pérdida de conexión")
|
||
self.chk_notif_error.setChecked(self.config.notify_on_error)
|
||
self.chk_notif_error.stateChanged.connect(self._save_notif_settings)
|
||
notif_layout.addWidget(self.chk_notif_error)
|
||
|
||
layout.addWidget(card_notif)
|
||
layout.addStretch()
|
||
|
||
# --- TAB 4: HISTORIAL ---
|
||
def _build_tab_history(self):
|
||
layout = QVBoxLayout(self.tab_history)
|
||
layout.setContentsMargins(16, 16, 16, 16)
|
||
layout.setSpacing(12)
|
||
|
||
lbl = QLabel("Historial de Archivos Respaldados Localmente")
|
||
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||
layout.addWidget(lbl)
|
||
|
||
self.tbl_history = QTableWidget(0, 4)
|
||
self.tbl_history.setHorizontalHeaderLabels(["Archivo", "Tamaño", "Integridad SHA-256", "Fecha de Respaldo"])
|
||
self.tbl_history.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
layout.addWidget(self.tbl_history)
|
||
|
||
self._refresh_history_table()
|
||
|
||
# --- TRAY ICON & WINDOW CLOSE BEHAVIOR ---
|
||
def _init_tray(self):
|
||
self.tray = QSystemTrayIcon(self)
|
||
self.tray.setIcon(self.app_icon)
|
||
self.tray.setToolTip(f"OnEver Drive — {self.config.client_code or 'Sin Registrar'}")
|
||
|
||
self._build_tray_menu()
|
||
self.tray.activated.connect(self._on_tray_activated)
|
||
self.tray.show()
|
||
|
||
def _build_tray_menu(self):
|
||
menu = QMenu()
|
||
client_label = self.config.client_code or "Sin Registrar"
|
||
act_title = QAction(f"OnEver Drive ({client_label})", self)
|
||
act_title.setEnabled(False)
|
||
menu.addAction(act_title)
|
||
|
||
menu.addSeparator()
|
||
|
||
act_open = QAction("🖥️ Abrir Panel de Control", self)
|
||
act_open.triggered.connect(self.showNormal)
|
||
menu.addAction(act_open)
|
||
|
||
act_sync = QAction("▶ Respaldar Todo Ahora", self)
|
||
act_sync.triggered.connect(self._trigger_all_backups)
|
||
menu.addAction(act_sync)
|
||
|
||
menu.addSeparator()
|
||
|
||
# Notification direct toggle in tray
|
||
self.act_tray_notif = QAction("🔔 Notificaciones Activadas" if self.config.enable_notifications else "🔕 Notificaciones Silenciadas", self)
|
||
self.act_tray_notif.triggered.connect(self._toggle_tray_notifications)
|
||
menu.addAction(self.act_tray_notif)
|
||
|
||
menu.addSeparator()
|
||
|
||
act_exit = QAction("❌ Salir", self)
|
||
act_exit.triggered.connect(self._clean_exit)
|
||
menu.addAction(act_exit)
|
||
|
||
self.tray.setContextMenu(menu)
|
||
|
||
def _toggle_tray_notifications(self):
|
||
self.config = load_config()
|
||
self.config.enable_notifications = not self.config.enable_notifications
|
||
save_config(self.config)
|
||
self.chk_notif_main.setChecked(self.config.enable_notifications)
|
||
self._build_tray_menu()
|
||
|
||
def _save_notif_settings(self):
|
||
self.config = load_config()
|
||
self.config.enable_notifications = self.chk_notif_main.isChecked()
|
||
self.config.notify_on_start = self.chk_notif_start.isChecked()
|
||
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
|
||
self.config.notify_on_error = self.chk_notif_error.isChecked()
|
||
save_config(self.config)
|
||
self._build_tray_menu()
|
||
|
||
def _on_tray_activated(self, reason):
|
||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||
self.showNormal()
|
||
self.activateWindow()
|
||
|
||
def closeEvent(self, event):
|
||
"""Minimize silently to system tray on close without showing intrusive popups."""
|
||
event.ignore()
|
||
self.hide()
|
||
|
||
def _clean_exit(self):
|
||
self.daemon.stop()
|
||
QApplication.quit()
|
||
|
||
# --- ACTIONS & NOTIFICATION TRIGGERS (Discrete: Start & Finish only) ---
|
||
def _update_header_status(self):
|
||
self.config = load_config()
|
||
if self.config.client_code:
|
||
self.lbl_status_badge.setText(f"● Conectado ({self.config.client_code})")
|
||
self.lbl_status_badge.setStyleSheet("background-color: #065F46; color: #34D399; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||
self.lbl_dash_client.setText(f"Cliente ID: {self.config.client_code} ({self.config.client_name or 'Local'})")
|
||
self.lbl_dash_server.setText(f"Servidor Proxmox: {self.config.server_url}")
|
||
self.lbl_dash_folders.setText(f"Carpetas en Monitoreo: {len(self.config.local_folders)}")
|
||
else:
|
||
self.lbl_status_badge.setText("● Sin Registrar")
|
||
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||
|
||
def _show_add_folder_dialog(self):
|
||
dialog = AddFolderDialog(self)
|
||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||
new_job = dialog.get_data()
|
||
self.config = load_config()
|
||
self.config.local_folders.append(new_job)
|
||
save_config(self.config)
|
||
self._refresh_folders_table()
|
||
self._update_header_status()
|
||
QMessageBox.information(self, "Carpeta Añadida", f"La carpeta '{new_job.name}' ha sido configurada y está siendo monitoreada.")
|
||
|
||
def _refresh_folders_table(self):
|
||
self.config = load_config()
|
||
self.tbl_folders.setRowCount(len(self.config.local_folders))
|
||
for row, job in enumerate(self.config.local_folders):
|
||
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name))
|
||
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path))
|
||
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns))
|
||
self.tbl_folders.setItem(row, 3, QTableWidgetItem(f"{job.schedule_interval_minutes} min"))
|
||
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera"))
|
||
|
||
def _delete_selected_folder(self):
|
||
row = self.tbl_folders.currentRow()
|
||
if row < 0:
|
||
QMessageBox.warning(self, "Selección", "Por favor selecciona una carpeta para eliminar.")
|
||
return
|
||
|
||
job_name = self.tbl_folders.item(row, 0).text()
|
||
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?")
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self.config = load_config()
|
||
if row < len(self.config.local_folders):
|
||
self.config.local_folders.pop(row)
|
||
save_config(self.config)
|
||
self._refresh_folders_table()
|
||
self._update_header_status()
|
||
|
||
def _test_server_connection(self):
|
||
url = self.txt_server_url.text().strip().rstrip("/")
|
||
if not url:
|
||
QMessageBox.warning(self, "Error", "Ingresa una URL de servidor.")
|
||
return
|
||
|
||
try:
|
||
t0 = time.time()
|
||
with httpx.Client(timeout=5.0) as client:
|
||
r = client.get(f"{url}/health")
|
||
elapsed_ms = int((time.time() - t0) * 1000)
|
||
if r.status_code == 200:
|
||
QMessageBox.information(self, "Conexión Exitosa", f"✓ Servidor OnEver Drive alcanzable.\nLatencia: {elapsed_ms} ms\nRespuesta: {r.json()}")
|
||
else:
|
||
QMessageBox.warning(self, "Error", f"El servidor respondió con código {r.status_code}")
|
||
except Exception as ex:
|
||
QMessageBox.critical(self, "Error de Conexión", f"No se pudo contactar al servidor en {url}:\n{str(ex)}")
|
||
|
||
def _register_device_api(self):
|
||
server_url = self.txt_server_url.text().strip().rstrip("/")
|
||
code = self.txt_reg_code.text().strip().upper()
|
||
if not server_url or not code:
|
||
QMessageBox.warning(self, "Error", "Debes ingresar la URL del servidor y el código de registro.")
|
||
return
|
||
|
||
try:
|
||
hostname = socket.gethostname()
|
||
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
|
||
payload = {
|
||
"registration_code": code,
|
||
"name": hostname,
|
||
"hostname": hostname,
|
||
"os_info": os_info,
|
||
"agent_version": "1.0.0"
|
||
}
|
||
with httpx.Client(timeout=15.0) as client:
|
||
resp = client.post(f"{server_url}/api/clients/register", json=payload)
|
||
if resp.status_code != 200:
|
||
QMessageBox.warning(self, "Registro Fallido", f"El servidor denegó el registro: {resp.text}")
|
||
return
|
||
data = resp.json()
|
||
self.config.server_url = server_url
|
||
self.config.client_code = data["client_code"]
|
||
self.config.device_id = data["device_id"]
|
||
self.config.device_token = data["device_token"]
|
||
self.config.client_name = data["name"]
|
||
save_config(self.config)
|
||
|
||
self._update_header_status()
|
||
self._build_tray_menu()
|
||
self.daemon.start()
|
||
|
||
QMessageBox.information(self, "Registro Exitoso", f"¡Dispositivo vinculado con éxito!\nCliente: {data['client_code']}")
|
||
except Exception as ex:
|
||
QMessageBox.critical(self, "Error", f"Error durante el registro: {str(ex)}")
|
||
|
||
def _trigger_all_backups(self):
|
||
if not self.config.device_id:
|
||
QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.")
|
||
return
|
||
|
||
threading.Thread(target=self.daemon._run_backup_cycle, daemon=True).start()
|
||
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
|
||
|
||
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
|
||
def _on_backup_started(self, filename: str, file_size: int):
|
||
mb = file_size / (1024 * 1024)
|
||
self.lbl_transfer_info.setText(f"Iniciando respaldo de: {filename} ({mb:.2f} MB)")
|
||
|
||
# Single notification at START of backup (if enabled)
|
||
if self.config.enable_notifications and self.config.notify_on_start:
|
||
self.tray.showMessage(
|
||
"OnEver Drive — Inicio de Respaldo",
|
||
f"Iniciando transferencia de {filename} ({mb:.2f} MB)...",
|
||
QSystemTrayIcon.MessageIcon.Information,
|
||
2500
|
||
)
|
||
|
||
def _on_live_progress(self, filename: str, done: int, total: int, pct: float):
|
||
# Progress updates ONLY update the GUI progressbar silently (no popups)
|
||
self.pbar_transfer.setValue(int(pct))
|
||
self.lbl_transfer_info.setText(f"Subiendo {filename}...")
|
||
self.lbl_chunk_details.setText(f"Chunks: {done} / {total} ({pct:.1f}%) | Motor por Bloques de 4MB Activo")
|
||
|
||
def _on_backup_completed(self, filename: str, sha256: str, file_size: int):
|
||
self.pbar_transfer.setValue(100)
|
||
self.lbl_transfer_info.setText(f"✓ Backup verificado e íntegro: {filename}")
|
||
self.lbl_chunk_details.setText(f"SHA-256: {sha256[:16]}... | Tamaño: {file_size / (1024*1024):.2f} MB")
|
||
|
||
# Single notification at FINISH of backup (if enabled)
|
||
if self.config.enable_notifications and self.config.notify_on_complete:
|
||
self.tray.showMessage(
|
||
"OnEver Drive — Respaldo Exitoso ✓",
|
||
f"{filename} respaldado y verificado en el servidor central (SHA-256).",
|
||
QSystemTrayIcon.MessageIcon.Information,
|
||
3000
|
||
)
|
||
self._refresh_history_table()
|
||
self._refresh_folders_table()
|
||
|
||
def _on_backup_error(self, filename: str, err: str):
|
||
self.lbl_transfer_info.setText(f"✗ Error al respaldar {filename}")
|
||
self.lbl_chunk_details.setText(f"Detalle: {err}")
|
||
|
||
# Notification on ERROR (if enabled)
|
||
if self.config.enable_notifications and self.config.notify_on_error:
|
||
self.tray.showMessage(
|
||
"OnEver Drive — Error en Respaldo ✗",
|
||
f"Fallo al respaldar {filename}: {err}",
|
||
QSystemTrayIcon.MessageIcon.Warning,
|
||
4000
|
||
)
|
||
|
||
def _on_daemon_status(self, status: str, message: str):
|
||
if status == "ONLINE":
|
||
self._update_header_status()
|
||
|
||
def _refresh_history_table(self):
|
||
try:
|
||
with state_db._get_conn() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT filepath, file_size, sha256, last_backup_time FROM completed_files ORDER BY last_backup_time DESC LIMIT 50")
|
||
rows = cursor.fetchall()
|
||
self.tbl_history.setRowCount(len(rows))
|
||
for r_idx, row in enumerate(rows):
|
||
self.tbl_history.setItem(r_idx, 0, QTableWidgetItem(Path(row[0]).name))
|
||
self.tbl_history.setItem(r_idx, 1, QTableWidgetItem(f"{row[1] / (1024*1024):.2f} MB"))
|
||
self.tbl_history.setItem(r_idx, 2, QTableWidgetItem(row[2][:16] + "..."))
|
||
self.tbl_history.setItem(r_idx, 3, QTableWidgetItem(str(row[3])))
|
||
except Exception:
|
||
pass
|
||
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
app.setQuitOnLastWindowClosed(False)
|
||
window = OnEverDriveMainWindow()
|
||
window.show()
|
||
sys.exit(app.exec())
|
||
|
||
if __name__ == "__main__":
|
||
main()
|