feature/integracion-cpanel-asrecovery
This commit is contained in:
36
app/config/__init__.py
Normal file
36
app/config/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Configuración de entorno, bootstrap y arranque."""
|
||||
|
||||
from .bootstrap import ensure_runtime_layout
|
||||
from .env_loader import (
|
||||
apply_env_overrides,
|
||||
get_launch_options,
|
||||
is_panel_configured,
|
||||
load_env_file,
|
||||
)
|
||||
from .odbc_setup import configure_odbc_environment
|
||||
from .autostart import register_autostart_if_requested
|
||||
|
||||
|
||||
def initialize_runtime() -> None:
|
||||
"""Prepara directorios, ODBC y auto-arranque (llamar antes de importar pyodbc)."""
|
||||
ensure_runtime_layout()
|
||||
configure_odbc_environment()
|
||||
|
||||
|
||||
def initialize_env() -> None:
|
||||
"""Post-bootstrap: carga .env y registra auto-arranque si aplica."""
|
||||
load_env_file()
|
||||
register_autostart_if_requested()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"initialize_runtime",
|
||||
"initialize_env",
|
||||
"ensure_runtime_layout",
|
||||
"load_env_file",
|
||||
"apply_env_overrides",
|
||||
"get_launch_options",
|
||||
"is_panel_configured",
|
||||
"configure_odbc_environment",
|
||||
"register_autostart_if_requested",
|
||||
]
|
||||
88
app/config/autostart.py
Normal file
88
app/config/autostart.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Registro de arranque automático al login (Windows / Linux)."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..constants import APP_DIR, CONFIG_DIR, IS_WINDOWS
|
||||
from .env_loader import _env_bool, load_env_file
|
||||
|
||||
|
||||
def _marker_path() -> Path:
|
||||
return CONFIG_DIR / ".autostart_registered"
|
||||
|
||||
|
||||
def _executable_command() -> tuple[str, list[str]]:
|
||||
if getattr(sys, "frozen", False):
|
||||
exe = Path(sys.executable).resolve()
|
||||
return str(exe), ["--start-engine"]
|
||||
runner = (APP_DIR / "runner.py").resolve()
|
||||
return sys.executable, [str(runner), "--start-engine"]
|
||||
|
||||
|
||||
def _register_windows(exe: str, args: list[str]) -> None:
|
||||
task_name = "CloudRestoreAS"
|
||||
arg_str = " ".join(f'"{a}"' if " " in a else a for a in args)
|
||||
tr = f'"{exe}" {arg_str}'
|
||||
check = subprocess.run(
|
||||
["schtasks", "/Query", "/TN", task_name],
|
||||
capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
||||
)
|
||||
if check.returncode == 0:
|
||||
return
|
||||
subprocess.run(
|
||||
[
|
||||
"schtasks",
|
||||
"/Create",
|
||||
"/TN",
|
||||
task_name,
|
||||
"/TR",
|
||||
tr,
|
||||
"/SC",
|
||||
"ONLOGON",
|
||||
"/RL",
|
||||
"LIMITED",
|
||||
"/F",
|
||||
],
|
||||
check=False,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
||||
)
|
||||
|
||||
|
||||
def _register_linux(exe: str, args: list[str]) -> None:
|
||||
autostart_dir = Path.home() / ".config" / "autostart"
|
||||
autostart_dir.mkdir(parents=True, exist_ok=True)
|
||||
desktop = autostart_dir / "cloudrestoreas.desktop"
|
||||
if desktop.exists():
|
||||
return
|
||||
exec_line = " ".join([exe, *args])
|
||||
content = f"""[Desktop Entry]
|
||||
Type=Application
|
||||
Name=CloudRestoreAS
|
||||
Comment=Restauración automática SQL Server
|
||||
Exec={exec_line}
|
||||
Path={APP_DIR}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
"""
|
||||
desktop.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def register_autostart_if_requested() -> None:
|
||||
load_env_file()
|
||||
if not _env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True):
|
||||
return
|
||||
if _marker_path().exists():
|
||||
return
|
||||
|
||||
exe, args = _executable_command()
|
||||
try:
|
||||
if IS_WINDOWS:
|
||||
_register_windows(exe, args)
|
||||
else:
|
||||
_register_linux(exe, args)
|
||||
_marker_path().write_text("ok", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
83
app/config/bootstrap.py
Normal file
83
app/config/bootstrap.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Creación automática de config/ y carpetas de trabajo."""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..constants import (
|
||||
APP_DIR,
|
||||
BUNDLE_DIR,
|
||||
BUNDLED_SOURCE_7ZIP,
|
||||
BUNDLED_SOURCE_ODBC,
|
||||
CONFIG_DIR,
|
||||
DATA_DIR,
|
||||
DIR_ENTRADA,
|
||||
DIR_FALLADOS,
|
||||
DIR_PROCESADOS,
|
||||
DIR_TEMP,
|
||||
ENV_PATH,
|
||||
LOGS_DIR,
|
||||
ODBC_DIR,
|
||||
SEVEN_ZIP_DIR,
|
||||
)
|
||||
from ..db.database import DatabaseManager
|
||||
from .env_loader import render_env_template
|
||||
|
||||
|
||||
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
|
||||
if not src.is_dir() or dest.exists():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dest)
|
||||
|
||||
|
||||
def _write_env_if_missing() -> None:
|
||||
if ENV_PATH.exists():
|
||||
return
|
||||
template_path = BUNDLE_DIR / "packaging" / "templates" / "env.default"
|
||||
if not template_path.is_file():
|
||||
template_path = APP_DIR / "packaging" / "templates" / "env.default"
|
||||
if template_path.is_file():
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
content = content.replace("{APP_DIR}", str(APP_DIR))
|
||||
else:
|
||||
content = render_env_template(APP_DIR)
|
||||
ENV_PATH.write_text(content, encoding="utf-8")
|
||||
try:
|
||||
ENV_PATH.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_runtime_layout() -> None:
|
||||
"""
|
||||
Idempotente: crea config/, carpetas de trabajo y .env si no existen.
|
||||
No sobrescribe .env ni app.db existentes.
|
||||
"""
|
||||
for directory in (
|
||||
CONFIG_DIR,
|
||||
DATA_DIR,
|
||||
LOGS_DIR,
|
||||
ODBC_DIR,
|
||||
SEVEN_ZIP_DIR,
|
||||
DIR_ENTRADA,
|
||||
DIR_PROCESADOS,
|
||||
DIR_FALLADOS,
|
||||
DIR_TEMP,
|
||||
):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_write_env_if_missing()
|
||||
|
||||
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
|
||||
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
|
||||
|
||||
from ..constants import DB_PATH
|
||||
|
||||
if not DB_PATH.exists():
|
||||
DatabaseManager(DB_PATH)
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
marker = CONFIG_DIR / ".bootstrap_ok"
|
||||
if not marker.exists():
|
||||
marker.write_text("ok", encoding="utf-8")
|
||||
138
app/config/env_loader.py
Normal file
138
app/config/env_loader.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Carga de variables desde config/.env."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from ..constants import (
|
||||
APP_DIR,
|
||||
DIR_ENTRADA,
|
||||
DIR_FALLADOS,
|
||||
DIR_PROCESADOS,
|
||||
DIR_TEMP,
|
||||
ENV_PATH,
|
||||
default_seven_zip_path,
|
||||
)
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def load_env_file() -> bool:
|
||||
"""Carga config/.env si existe."""
|
||||
if ENV_PATH.is_file():
|
||||
load_dotenv(ENV_PATH, override=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_panel_configured() -> bool:
|
||||
"""True si URL, token e instancia del panel están definidos."""
|
||||
url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
|
||||
token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
|
||||
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
|
||||
return bool(url and token and instance)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaunchOptions:
|
||||
minimized: bool
|
||||
start_engine: bool
|
||||
register_autostart: bool
|
||||
|
||||
|
||||
def get_launch_options() -> LaunchOptions:
|
||||
load_env_file()
|
||||
return LaunchOptions(
|
||||
minimized=_env_bool("CLOUDRESTORE_START_MINIMIZED", False),
|
||||
start_engine=_env_bool("CLOUDRESTORE_AUTO_START", True),
|
||||
register_autostart=_env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True),
|
||||
)
|
||||
|
||||
|
||||
def _path_from_env(name: str, default: Path) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
return value if value else str(default)
|
||||
|
||||
|
||||
def apply_env_overrides(config: dict) -> dict:
|
||||
"""Aplica variables de entorno sobre la configuración en memoria."""
|
||||
load_env_file()
|
||||
|
||||
paths = config.setdefault("paths", {})
|
||||
paths["input_folder"] = _path_from_env("CLOUDRESTORE_INPUT_FOLDER", DIR_ENTRADA)
|
||||
paths["processed_folder"] = _path_from_env(
|
||||
"CLOUDRESTORE_PROCESSED_FOLDER", DIR_PROCESADOS
|
||||
)
|
||||
paths["failed_folder"] = _path_from_env("CLOUDRESTORE_FAILED_FOLDER", DIR_FALLADOS)
|
||||
paths["extract_folder"] = _path_from_env("CLOUDRESTORE_EXTRACT_FOLDER", DIR_TEMP)
|
||||
data_sql = os.getenv("CLOUDRESTORE_DATA_SQL_FOLDER", "").strip()
|
||||
if data_sql:
|
||||
paths["data_sql_folder"] = data_sql
|
||||
|
||||
seven_zip = os.getenv("CLOUDRESTORE_SEVEN_ZIP_EXE", "").strip()
|
||||
paths["seven_zip_exe"] = seven_zip or default_seven_zip_path()
|
||||
|
||||
panel = config.setdefault("panel", {})
|
||||
api_url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
|
||||
if api_url:
|
||||
panel["api_url"] = api_url
|
||||
api_token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
|
||||
if api_token:
|
||||
panel["api_token"] = api_token
|
||||
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
|
||||
if instance:
|
||||
panel["instance_key"] = instance
|
||||
if os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL"):
|
||||
panel["verify_ssl"] = _env_bool("CLOUDRESTORE_PANEL_VERIFY_SSL", False)
|
||||
|
||||
sql = config.setdefault("sql", {})
|
||||
server = os.getenv("CLOUDRESTORE_SQL_SERVER", "").strip()
|
||||
if server:
|
||||
sql["server"] = server
|
||||
if os.getenv("CLOUDRESTORE_SQL_USERNAME"):
|
||||
sql["username"] = os.getenv("CLOUDRESTORE_SQL_USERNAME", "").strip()
|
||||
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
|
||||
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def render_env_template(app_dir: Path | None = None) -> str:
|
||||
"""Genera contenido inicial de config/.env."""
|
||||
base = (app_dir or APP_DIR).resolve()
|
||||
sep = "\\" if os.name == "nt" else "/"
|
||||
|
||||
def p(*parts: str) -> str:
|
||||
return sep.join([str(base), *parts])
|
||||
|
||||
return f"""# CloudRestoreAS — configuración local (editar y reiniciar la app)
|
||||
|
||||
# Arranca el motor al abrir (la ventana siempre se muestra salvo START_MINIMIZED=true)
|
||||
CLOUDRESTORE_AUTO_START=true
|
||||
CLOUDRESTORE_START_MINIMIZED=false
|
||||
CLOUDRESTORE_REGISTER_AUTOSTART=true
|
||||
|
||||
# Servicio PANEL_BASES_ANEXO24 (enrutamiento automático de nodos)
|
||||
CLOUDRESTORE_PANEL_API_URL=
|
||||
CLOUDRESTORE_PANEL_API_TOKEN=
|
||||
CLOUDRESTORE_PANEL_INSTANCE_KEY=
|
||||
CLOUDRESTORE_PANEL_VERIFY_SSL=false
|
||||
|
||||
# Carpetas de trabajo
|
||||
CLOUDRESTORE_INPUT_FOLDER={p("Entrada")}
|
||||
CLOUDRESTORE_PROCESSED_FOLDER={p("Procesados")}
|
||||
CLOUDRESTORE_FAILED_FOLDER={p("Fallados")}
|
||||
CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
|
||||
|
||||
# SQL local (solo si no usa panel para credenciales)
|
||||
# CLOUDRESTORE_SQL_SERVER=localhost
|
||||
# CLOUDRESTORE_SQL_USERNAME=
|
||||
# CLOUDRESTORE_DATA_SQL_FOLDER=
|
||||
"""
|
||||
71
app/config/odbc_setup.py
Normal file
71
app/config/odbc_setup.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Configura ODBC portable desde config/odbc/."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..constants import IS_WINDOWS, ODBC_DIR
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
def _write_portable_odbc_ini(odbc_dir: Path) -> None:
|
||||
"""Genera odbcinst.ini mínimo si no existe."""
|
||||
inst = odbc_dir / "odbcinst.ini"
|
||||
if inst.exists():
|
||||
return
|
||||
|
||||
if IS_WINDOWS:
|
||||
driver_path = odbc_dir / "msodbcsql18.dll"
|
||||
if not driver_path.exists():
|
||||
for candidate in odbc_dir.glob("msodbcsql*.dll"):
|
||||
driver_path = candidate
|
||||
break
|
||||
driver_line = str(driver_path.resolve()) if driver_path.exists() else "msodbcsql18.dll"
|
||||
content = f"""[ODBC Driver 18 for SQL Server]
|
||||
Description=Microsoft ODBC Driver 18 for SQL Server
|
||||
Driver={driver_line}
|
||||
UsageCount=1
|
||||
"""
|
||||
else:
|
||||
so_path = None
|
||||
for pattern in ("libmsodbcsql-18*.so*", "msodbcsql-18*.so*"):
|
||||
matches = list(odbc_dir.glob(pattern))
|
||||
if matches:
|
||||
so_path = matches[0]
|
||||
break
|
||||
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
|
||||
content = f"""[ODBC Driver 18 for SQL Server]
|
||||
Description=Microsoft ODBC Driver 18 for SQL Server
|
||||
Driver={driver_line}
|
||||
UsageCount=1
|
||||
"""
|
||||
inst.write_text(content, encoding="utf-8")
|
||||
|
||||
ini = odbc_dir / "odbc.ini"
|
||||
if not ini.exists():
|
||||
ini.write_text("[ODBC Data Sources]\n", encoding="utf-8")
|
||||
|
||||
|
||||
def configure_odbc_environment() -> None:
|
||||
"""Apunta pyodbc al driver embebido en config/odbc si está disponible."""
|
||||
if not ODBC_DIR.is_dir():
|
||||
return
|
||||
|
||||
lib_dir = ODBC_DIR / "lib"
|
||||
if lib_dir.is_dir():
|
||||
existing = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
lib_path = str(lib_dir.resolve())
|
||||
if lib_path not in existing.split(":"):
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
f"{lib_path}:{existing}" if existing else lib_path
|
||||
)
|
||||
|
||||
_write_portable_odbc_ini(ODBC_DIR)
|
||||
odbc_sys = str(ODBC_DIR.resolve())
|
||||
os.environ["ODBCSYSINI"] = odbc_sys
|
||||
os.environ["ODBCINI"] = str((ODBC_DIR / "odbc.ini").resolve())
|
||||
|
||||
if IS_WINDOWS:
|
||||
os.environ["PATH"] = odbc_sys + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
app_logger.debug(f"ODBC configurado: ODBCSYSINI={odbc_sys}")
|
||||
106
app/constants.py
106
app/constants.py
@@ -1,13 +1,51 @@
|
||||
"""Constantes globales de la aplicación."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Rutas base
|
||||
APP_DIR = Path(__file__).parent.parent
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
LOGS_DIR = APP_DIR / "logs"
|
||||
|
||||
# Base de datos
|
||||
def _resolve_app_dir() -> Path:
|
||||
"""Directorio donde vive el ejecutable (persistente)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _resolve_bundle_dir() -> Path:
|
||||
"""Recursos embebidos en el binario PyInstaller onefile."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys._MEIPASS)
|
||||
platform = "windows" if sys.platform == "win32" else "linux"
|
||||
return _resolve_app_dir() / "packaging" / "bundled" / platform
|
||||
|
||||
|
||||
APP_DIR = _resolve_app_dir()
|
||||
CONFIG_DIR = APP_DIR / "config"
|
||||
ENV_PATH = CONFIG_DIR / ".env"
|
||||
DATA_DIR = CONFIG_DIR / "data"
|
||||
LOGS_DIR = CONFIG_DIR / "logs"
|
||||
ODBC_DIR = CONFIG_DIR / "odbc"
|
||||
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
BUNDLE_DIR = _resolve_bundle_dir()
|
||||
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
|
||||
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
|
||||
def _default_7zip_name() -> str:
|
||||
if IS_WINDOWS:
|
||||
return "7z.exe"
|
||||
return "7zz"
|
||||
|
||||
|
||||
BUNDLED_7ZIP_EXE = SEVEN_ZIP_DIR / _default_7zip_name()
|
||||
BUNDLED_7ZIP_FALLBACK = SEVEN_ZIP_DIR / ("7za.exe" if IS_WINDOWS else "7zz")
|
||||
|
||||
# Carpetas de trabajo junto al ejecutable
|
||||
DIR_ENTRADA = APP_DIR / "Entrada"
|
||||
DIR_PROCESADOS = APP_DIR / "Procesados"
|
||||
DIR_FALLADOS = APP_DIR / "Fallados"
|
||||
DIR_TEMP = APP_DIR / "Temp"
|
||||
|
||||
DB_PATH = DATA_DIR / "app.db"
|
||||
|
||||
# Estados de jobs
|
||||
@@ -21,7 +59,7 @@ class JobStatus:
|
||||
FAILED_RESTART = "failed_restart"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
# Niveles de log
|
||||
|
||||
class LogLevel:
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
@@ -29,7 +67,7 @@ class LogLevel:
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
# Tipos de paso de job
|
||||
|
||||
class StepType:
|
||||
STABILITY_CHECK = "stability_check"
|
||||
NODE_MAPPING = "node_mapping"
|
||||
@@ -39,57 +77,63 @@ class StepType:
|
||||
FILELIST = "filelist"
|
||||
RESTORE = "restore"
|
||||
CLEANUP = "cleanup"
|
||||
SFTP_COPY = "sftp_copy" # Transferencia del .bak al servidor SQL externo (SFTP/SSH)
|
||||
FORWARD_ZIP = "forward_zip" # Reenvío del ZIP al input_folder del servidor destino
|
||||
SFTP_COPY = "sftp_copy"
|
||||
FORWARD_ZIP = "forward_zip"
|
||||
|
||||
# Configuración por defecto
|
||||
DEFAULT_CONFIG = {
|
||||
"paths": {
|
||||
"input_folder": "",
|
||||
"processed_folder": "",
|
||||
"failed_folder": "",
|
||||
"extract_folder": "",
|
||||
|
||||
def default_seven_zip_path() -> str:
|
||||
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
def default_work_paths() -> dict[str, str]:
|
||||
return {
|
||||
"input_folder": str(DIR_ENTRADA),
|
||||
"processed_folder": str(DIR_PROCESADOS),
|
||||
"failed_folder": str(DIR_FALLADOS),
|
||||
"extract_folder": str(DIR_TEMP),
|
||||
"data_sql_folder": "",
|
||||
"seven_zip_exe": ""
|
||||
},
|
||||
"seven_zip_exe": default_seven_zip_path(),
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"paths": default_work_paths(),
|
||||
"sql": {
|
||||
"server": "localhost",
|
||||
"use_windows_auth": True,
|
||||
"use_windows_auth": IS_WINDOWS,
|
||||
"username": "",
|
||||
"password_encrypted": ""
|
||||
"password_encrypted": "",
|
||||
},
|
||||
"concurrency": {
|
||||
"extract_workers": 1,
|
||||
"restore_workers": 1
|
||||
"restore_workers": 1,
|
||||
},
|
||||
"stability": {
|
||||
"check_enabled": True,
|
||||
"check_interval_seconds": 5,
|
||||
"stable_duration_seconds": 10,
|
||||
"use_ready_marker": False
|
||||
"use_ready_marker": False,
|
||||
},
|
||||
"timeouts": {
|
||||
"extract_minutes": 30,
|
||||
"restore_minutes": 60
|
||||
"restore_minutes": 60,
|
||||
},
|
||||
"retries": {
|
||||
"max_attempts": 3,
|
||||
"retry_delay_seconds": 60
|
||||
"retry_delay_seconds": 60,
|
||||
},
|
||||
"features": {
|
||||
"dry_run_mode": False,
|
||||
"auto_scan_enabled": True,
|
||||
"scan_interval_seconds": 30
|
||||
"scan_interval_seconds": 30,
|
||||
},
|
||||
# Integración con PANEL_BASES_ANEXO24. Si api_url está vacío, CloudRestoreAS
|
||||
# opera en modo local usando la sección "sql" (retrocompatibilidad).
|
||||
"panel": {
|
||||
"api_url": "",
|
||||
"api_token": "",
|
||||
# Nombre del restore_target en el panel (identidad de este CRA).
|
||||
# Con panel configurado: enrutamiento automático restore_local | forward.
|
||||
"instance_key": "",
|
||||
# False en dev: el panel local usa HTTPS con certificado propio.
|
||||
"verify_ssl": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from .. import __version__
|
||||
from ..db.job_repository import JobRepository
|
||||
from ..db.event_repository import EventRepository
|
||||
from ..db.config_repository import ConfigRepository
|
||||
from ..config.env_loader import apply_env_overrides, load_env_file
|
||||
from ..constants import JobStatus, DEFAULT_CONFIG
|
||||
from ..panel import panel_client
|
||||
from ..utils.logger import app_logger
|
||||
@@ -50,14 +51,15 @@ class RestoreEngine(QObject):
|
||||
app_logger.info("RestoreEngine inicializado")
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
"""Carga la configuración desde la base de datos."""
|
||||
"""Carga la configuración desde la base de datos y variables de entorno."""
|
||||
load_env_file()
|
||||
config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy())
|
||||
|
||||
# Asegurar que tiene todas las claves
|
||||
|
||||
for key, value in DEFAULT_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
|
||||
|
||||
config = apply_env_overrides(config)
|
||||
self.signals.config_loaded.emit(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -335,11 +335,17 @@ class RestoreWorker(QRunnable):
|
||||
stderr=stderr[:1000] if stderr else None
|
||||
)
|
||||
|
||||
# Buscar archivo .bak
|
||||
# Buscar archivo .bak. El backup puede venir con terminaciones extra
|
||||
# (p.ej. ".KNOWNWORLD") tanto en el ZIP como en el propio .bak; se
|
||||
# normaliza a "<nodo>.bak". El nodo es siempre el primer segmento del
|
||||
# nombre del archivo de origen.
|
||||
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
|
||||
|
||||
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir)
|
||||
|
||||
|
||||
node_base = Path(job.source_name).name.split(".")[0].strip() or None
|
||||
self._bak_path = SevenZipExtractor.find_bak_file(
|
||||
self._extract_dir, node_name=node_base
|
||||
)
|
||||
|
||||
if not self._bak_path:
|
||||
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Gestión de extracción de archivos con 7-Zip."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
import time
|
||||
|
||||
from ..constants import BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK, IS_WINDOWS
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
@@ -25,19 +29,30 @@ class SevenZipExtractor:
|
||||
|
||||
@staticmethod
|
||||
def _auto_detect_7zip() -> Optional[str]:
|
||||
"""Auto-detecta la ubicación de 7z.exe."""
|
||||
possible_paths = [
|
||||
r"C:\Program Files\7-Zip\7z.exe",
|
||||
r"D:\Program Files\7-Zip\7z.exe",
|
||||
r"C:\Program Files (x86)\7-Zip\7z.exe",
|
||||
r"D:\Program Files (x86)\7-Zip\7z.exe"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
app_logger.info(f"7-Zip auto-detectado en: {path}")
|
||||
return path
|
||||
|
||||
"""Prioriza 7-Zip embebido; en desarrollo busca en el sistema."""
|
||||
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
|
||||
if candidate.exists():
|
||||
app_logger.info(f"7-Zip embebido: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
if IS_WINDOWS:
|
||||
possible_paths = [
|
||||
r"C:\Program Files\7-Zip\7z.exe",
|
||||
r"C:\Program Files (x86)\7-Zip\7z.exe",
|
||||
]
|
||||
else:
|
||||
possible_paths = []
|
||||
for name in ("7z", "7zz", "7za"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
possible_paths.append(found)
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
app_logger.info(f"7-Zip detectado en: {path}")
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def extract(
|
||||
@@ -134,35 +149,78 @@ class SevenZipExtractor:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@staticmethod
|
||||
def find_bak_file(extract_dir: str) -> Optional[str]:
|
||||
def find_bak_file(extract_dir: str, node_name: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Busca el archivo .bak extraído en el directorio.
|
||||
|
||||
Busca el backup extraído y, si se indica node_name, lo normaliza a
|
||||
'<node_name>.bak'.
|
||||
|
||||
Tolera terminaciones extra en el nombre (el backup puede venir como
|
||||
'nodo.KNOWNWORLD.bak', 'nodo.bak.unknownworld' o incluso sin extensión
|
||||
'.bak'). Estrategia de búsqueda en orden:
|
||||
1. archivos que terminan exactamente en '.bak'
|
||||
2. si no hay, archivos cuyo nombre contiene '.bak' en cualquier parte
|
||||
3. si no hay, el archivo más grande de la extracción (el backup suele serlo)
|
||||
|
||||
Args:
|
||||
extract_dir: Directorio donde se extrajo
|
||||
|
||||
node_name: Nombre del nodo; si se indica, el backup se renombra a
|
||||
'<node_name>.bak'
|
||||
|
||||
Returns:
|
||||
Ruta al archivo .bak o None si no se encuentra
|
||||
|
||||
Raises:
|
||||
ValueError: Si hay múltiples archivos .bak
|
||||
Ruta al archivo .bak (ya renombrado si node_name) o None si no hay archivos.
|
||||
"""
|
||||
extract_path = Path(extract_dir)
|
||||
bak_files = list(extract_path.rglob("*.bak"))
|
||||
|
||||
if not bak_files:
|
||||
app_logger.error(f"No se encontró archivo .bak en {extract_dir}")
|
||||
all_files = [p for p in extract_path.rglob("*") if p.is_file()]
|
||||
|
||||
if not all_files:
|
||||
app_logger.error(f"No se encontró ningún archivo en {extract_dir}")
|
||||
return None
|
||||
|
||||
if len(bak_files) > 1:
|
||||
# Si hay múltiples, tomar el más reciente
|
||||
|
||||
bak_files = [p for p in all_files if p.suffix.lower() == ".bak"]
|
||||
if not bak_files:
|
||||
bak_files = [p for p in all_files if ".bak" in p.name.lower()]
|
||||
if not bak_files:
|
||||
largest = max(all_files, key=lambda p: p.stat().st_size)
|
||||
app_logger.warning(
|
||||
f"Múltiples archivos .bak encontrados ({len(bak_files)}), "
|
||||
"seleccionando el más reciente"
|
||||
"No se encontró archivo con extensión .bak; usando el archivo más "
|
||||
f"grande de la extracción: {largest.name}"
|
||||
)
|
||||
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
bak_path = str(bak_files[0])
|
||||
bak_files = [largest]
|
||||
|
||||
if len(bak_files) > 1:
|
||||
# Preferir el que empiece con el nombre del nodo; si no, el más reciente.
|
||||
if node_name:
|
||||
prefixed = [
|
||||
p for p in bak_files
|
||||
if p.name.lower().startswith(node_name.lower())
|
||||
]
|
||||
if prefixed:
|
||||
bak_files = prefixed
|
||||
if len(bak_files) > 1:
|
||||
app_logger.warning(
|
||||
f"Múltiples backups encontrados ({len(bak_files)}), "
|
||||
"seleccionando el más reciente"
|
||||
)
|
||||
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
bak_file = bak_files[0]
|
||||
|
||||
# Normalizar el nombre a '<node_name>.bak'.
|
||||
if node_name:
|
||||
target = bak_file.parent / f"{node_name}.bak"
|
||||
if bak_file.name != target.name:
|
||||
try:
|
||||
if target.exists() and not bak_file.samefile(target):
|
||||
target.unlink()
|
||||
bak_file = bak_file.rename(target)
|
||||
app_logger.info(f"Backup normalizado a: {bak_file.name}")
|
||||
except OSError as e:
|
||||
# No es fatal para el RESTORE: se continúa con el nombre original.
|
||||
app_logger.error(
|
||||
f"No se pudo renombrar el backup a '{target.name}': {e}"
|
||||
)
|
||||
|
||||
bak_path = str(bak_file)
|
||||
app_logger.info(f"Archivo .bak encontrado: {bak_path}")
|
||||
return bak_path
|
||||
|
||||
|
||||
@@ -4,8 +4,28 @@ import pyodbc
|
||||
from typing import Optional, Tuple, List
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
_ODBC_DRIVER_CANDIDATES = (
|
||||
"ODBC Driver 18 for SQL Server",
|
||||
"ODBC Driver 17 for SQL Server",
|
||||
"ODBC Driver 13 for SQL Server",
|
||||
"FreeTDS",
|
||||
)
|
||||
|
||||
|
||||
def resolve_odbc_driver() -> str:
|
||||
"""Devuelve el primer driver ODBC de SQL Server disponible."""
|
||||
installed = set(pyodbc.drivers())
|
||||
for name in _ODBC_DRIVER_CANDIDATES:
|
||||
if name in installed:
|
||||
return name
|
||||
raise RuntimeError(
|
||||
"No se encontró driver ODBC para SQL Server. "
|
||||
f"Instalados: {', '.join(pyodbc.drivers()) or 'ninguno'}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogicalFile:
|
||||
@@ -50,10 +70,13 @@ class SQLServerManager:
|
||||
Returns:
|
||||
Cadena de conexión ODBC
|
||||
"""
|
||||
driver = resolve_odbc_driver()
|
||||
parts = [
|
||||
f"DRIVER={{ODBC Driver 17 for SQL Server}}",
|
||||
f"SERVER={self.server}"
|
||||
f"DRIVER={{{driver}}}",
|
||||
f"SERVER={self.server}",
|
||||
]
|
||||
if "18" in driver:
|
||||
parts.append("TrustServerCertificate=yes")
|
||||
|
||||
if database:
|
||||
parts.append(f"DATABASE={database}")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tab de configuración."""
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QFormLayout, QLineEdit, QPushButton, QSpinBox,
|
||||
@@ -104,6 +106,12 @@ class ConfigTab(QWidget):
|
||||
self.sql_windows_auth_checkbox.setChecked(True)
|
||||
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
|
||||
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
|
||||
if sys.platform != "win32":
|
||||
self.sql_windows_auth_checkbox.setChecked(False)
|
||||
self.sql_windows_auth_checkbox.setEnabled(False)
|
||||
self.sql_windows_auth_checkbox.setToolTip(
|
||||
"Windows Auth no está disponible en Linux; use SQL Auth."
|
||||
)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
self.sql_username_input.setEnabled(False)
|
||||
|
||||
@@ -32,6 +32,14 @@ class DashboardTab(QWidget):
|
||||
title_font.setBold(True)
|
||||
title.setFont(title_font)
|
||||
layout.addWidget(title)
|
||||
|
||||
self.panel_warning_label = QLabel("")
|
||||
self.panel_warning_label.setWordWrap(True)
|
||||
self.panel_warning_label.setStyleSheet(
|
||||
"background-color: #fff3cd; color: #856404; padding: 8px; border-radius: 4px;"
|
||||
)
|
||||
self.panel_warning_label.hide()
|
||||
layout.addWidget(self.panel_warning_label)
|
||||
|
||||
# Grid de estadísticas
|
||||
stats_group = QGroupBox("Estadísticas de Jobs")
|
||||
@@ -94,17 +102,29 @@ class DashboardTab(QWidget):
|
||||
times_group.setLayout(times_layout)
|
||||
layout.addWidget(times_group)
|
||||
|
||||
self.engine_status_badge = QLabel("Detenido")
|
||||
badge_font = QFont()
|
||||
badge_font.setPointSize(12)
|
||||
badge_font.setBold(True)
|
||||
self.engine_status_badge.setFont(badge_font)
|
||||
self.engine_status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.engine_status_badge.setStyleSheet(
|
||||
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
layout.addWidget(self.engine_status_badge)
|
||||
|
||||
# Estado del motor
|
||||
motor_group = QGroupBox("Estado del Motor")
|
||||
motor_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.motor_status_label = QLabel("Detenido")
|
||||
motor_status_font = QFont()
|
||||
motor_status_font.setPointSize(14)
|
||||
motor_status_font.setBold(True)
|
||||
self.motor_status_label.setFont(motor_status_font)
|
||||
self.motor_status_label.setStyleSheet("color: red;")
|
||||
|
||||
|
||||
motor_layout.addWidget(QLabel("Estado:"))
|
||||
motor_layout.addWidget(self.motor_status_label)
|
||||
motor_layout.addStretch()
|
||||
@@ -136,6 +156,7 @@ class DashboardTab(QWidget):
|
||||
|
||||
def _load_initial_stats(self):
|
||||
"""Carga las estadísticas iniciales."""
|
||||
self.update_motor_status()
|
||||
stats = self.engine.get_stats()
|
||||
self.update_stats(stats)
|
||||
self._refresh_recent_jobs()
|
||||
@@ -193,3 +214,40 @@ class DashboardTab(QWidget):
|
||||
self.recent_table.setItem(i, 4, status_item)
|
||||
|
||||
self.recent_table.resizeColumnsToContents()
|
||||
|
||||
def update_motor_status(self) -> None:
|
||||
"""Actualiza badge y etiqueta según el estado actual del motor."""
|
||||
if not self.engine.is_running():
|
||||
text = "Detenido"
|
||||
badge_style = (
|
||||
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: red;"
|
||||
elif self.engine.is_paused():
|
||||
text = "Pausado"
|
||||
badge_style = (
|
||||
"background-color: #fff3cd; color: #856404; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: orange;"
|
||||
else:
|
||||
text = "Activo"
|
||||
badge_style = (
|
||||
"background-color: #d4edda; color: #155724; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: green;"
|
||||
|
||||
self.engine_status_badge.setText(f"Motor: {text}")
|
||||
self.engine_status_badge.setStyleSheet(badge_style)
|
||||
self.motor_status_label.setText(text)
|
||||
self.motor_status_label.setStyleSheet(label_style)
|
||||
|
||||
def set_panel_warning(self, message: str) -> None:
|
||||
"""Muestra aviso cuando falta configuración del servicio de bases en config/.env."""
|
||||
if message:
|
||||
self.panel_warning_label.setText(message)
|
||||
self.panel_warning_label.show()
|
||||
else:
|
||||
self.panel_warning_label.hide()
|
||||
|
||||
@@ -2,181 +2,207 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
|
||||
QSystemTrayIcon, QMenu
|
||||
QSystemTrayIcon, QMenu, QMessageBox, QApplication,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QIcon, QCloseEvent, QAction
|
||||
from PySide6.QtGui import QCloseEvent, QAction
|
||||
|
||||
from .dashboard_tab import DashboardTab
|
||||
from .jobs_tab import JobsTab
|
||||
from .nodes_tab import NodesTab
|
||||
from .config_tab import ConfigTab
|
||||
from .logs_tab import LogsTab
|
||||
from .tray_assets import load_tray_icon
|
||||
from ..engine.engine import RestoreEngine
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Ventana principal de la aplicación."""
|
||||
|
||||
# Señal para minimizar a tray
|
||||
|
||||
minimize_to_tray = Signal()
|
||||
|
||||
def __init__(self):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
minimized: bool = False,
|
||||
start_engine: bool = False,
|
||||
panel_configured: bool = True,
|
||||
):
|
||||
"""Inicializa la ventana principal."""
|
||||
super().__init__()
|
||||
|
||||
# Motor de restauración
|
||||
|
||||
self._start_on_load = start_engine
|
||||
self._minimized_on_load = minimized
|
||||
self._panel_configured = panel_configured
|
||||
self._force_quit = False
|
||||
self._tray_hint_shown = False
|
||||
|
||||
self.engine = RestoreEngine()
|
||||
|
||||
# Configurar ventana
|
||||
|
||||
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
|
||||
self.setMinimumSize(1200, 800)
|
||||
|
||||
# System tray
|
||||
self.tray_icon: QSystemTrayIcon = None
|
||||
|
||||
self.tray_icon: QSystemTrayIcon | None = None
|
||||
self._setup_tray()
|
||||
|
||||
# UI
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
# Conectar señales del motor
|
||||
self._connect_engine_signals()
|
||||
|
||||
# Timer para actualizar stats
|
||||
|
||||
self.stats_timer = QTimer()
|
||||
self.stats_timer.timeout.connect(self._update_stats)
|
||||
self.stats_timer.start(5000) # Cada 5 segundos
|
||||
|
||||
self.stats_timer.start(5000)
|
||||
|
||||
if not self._panel_configured:
|
||||
self.dashboard_tab.set_panel_warning(
|
||||
"Configure la conexión al servicio de bases en config/.env "
|
||||
"(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)."
|
||||
)
|
||||
|
||||
if self._start_on_load:
|
||||
QTimer.singleShot(500, self._start_engine)
|
||||
|
||||
if self._minimized_on_load:
|
||||
QTimer.singleShot(100, self.hide)
|
||||
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Ventana principal inicializada")
|
||||
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz de usuario."""
|
||||
# Widget central
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Layout principal
|
||||
|
||||
layout = QVBoxLayout(central_widget)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Tabs
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# Crear tabs
|
||||
|
||||
self.dashboard_tab = DashboardTab(self.engine)
|
||||
self.jobs_tab = JobsTab(self.engine)
|
||||
self.nodes_tab = NodesTab(self.engine)
|
||||
self.config_tab = ConfigTab(self.engine)
|
||||
self.logs_tab = LogsTab(self.engine)
|
||||
|
||||
# Agregar tabs
|
||||
|
||||
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
|
||||
self.tabs.addTab(self.jobs_tab, "📋 Jobs")
|
||||
self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
|
||||
self.tabs.addTab(self.config_tab, "⚙️ Configuración")
|
||||
self.tabs.addTab(self.logs_tab, "📝 Logs")
|
||||
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Menu bar
|
||||
self._setup_menu()
|
||||
|
||||
|
||||
def _setup_menu(self):
|
||||
"""Configura el menú."""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# Menú Motor
|
||||
|
||||
file_menu = menubar.addMenu("Archivo")
|
||||
|
||||
quit_action = QAction("Salir", self)
|
||||
quit_action.triggered.connect(self._quit_application)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
motor_menu = menubar.addMenu("Motor")
|
||||
|
||||
|
||||
self.start_action = QAction("▶️ Iniciar", self)
|
||||
self.start_action.triggered.connect(self._start_engine)
|
||||
motor_menu.addAction(self.start_action)
|
||||
|
||||
|
||||
self.pause_action = QAction("⏸️ Pausar", self)
|
||||
self.pause_action.triggered.connect(self._pause_engine)
|
||||
self.pause_action.setEnabled(False)
|
||||
motor_menu.addAction(self.pause_action)
|
||||
|
||||
|
||||
self.resume_action = QAction("▶️ Continuar", self)
|
||||
self.resume_action.triggered.connect(self._resume_engine)
|
||||
self.resume_action.setEnabled(False)
|
||||
motor_menu.addAction(self.resume_action)
|
||||
|
||||
|
||||
self.stop_action = QAction("⏹️ Detener", self)
|
||||
self.stop_action.triggered.connect(self._stop_engine)
|
||||
self.stop_action.setEnabled(False)
|
||||
motor_menu.addAction(self.stop_action)
|
||||
|
||||
|
||||
motor_menu.addSeparator()
|
||||
|
||||
|
||||
self.scan_action = QAction("🔍 Escanear Ahora", self)
|
||||
self.scan_action.triggered.connect(self._scan_now)
|
||||
motor_menu.addAction(self.scan_action)
|
||||
|
||||
# Menú Ayuda
|
||||
|
||||
help_menu = menubar.addMenu("Ayuda")
|
||||
|
||||
|
||||
about_action = QAction("Acerca de", self)
|
||||
about_action.triggered.connect(self._show_about)
|
||||
help_menu.addAction(about_action)
|
||||
|
||||
|
||||
def _setup_tray(self):
|
||||
"""Configura el icono de bandeja del sistema."""
|
||||
# Crear icono (usar un icono por defecto o crear uno simple)
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
app_logger.warning("Bandeja del sistema no disponible en este entorno")
|
||||
return
|
||||
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
self.tray_icon.setIcon(load_tray_icon())
|
||||
self.tray_icon.setToolTip("CloudRestoreAS")
|
||||
|
||||
# Menú del tray
|
||||
|
||||
tray_menu = QMenu()
|
||||
|
||||
|
||||
show_action = QAction("Mostrar", self)
|
||||
show_action.triggered.connect(self._show_window)
|
||||
tray_menu.addAction(show_action)
|
||||
|
||||
|
||||
hide_action = QAction("Ocultar", self)
|
||||
hide_action.triggered.connect(self.hide)
|
||||
tray_menu.addAction(hide_action)
|
||||
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
|
||||
pause_tray_action = QAction("Pausar", self)
|
||||
pause_tray_action.triggered.connect(self._pause_engine)
|
||||
tray_menu.addAction(pause_tray_action)
|
||||
|
||||
|
||||
resume_tray_action = QAction("Continuar", self)
|
||||
resume_tray_action.triggered.connect(self._resume_engine)
|
||||
tray_menu.addAction(resume_tray_action)
|
||||
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
quit_action = QAction("Salir", self)
|
||||
quit_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_action)
|
||||
|
||||
|
||||
quit_tray_action = QAction("Salir", self)
|
||||
quit_tray_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_tray_action)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
|
||||
# Doble clic en tray para mostrar/ocultar
|
||||
self.tray_icon.activated.connect(self._on_tray_activated)
|
||||
|
||||
# Mostrar tray
|
||||
self.tray_icon.show()
|
||||
|
||||
|
||||
def _connect_engine_signals(self):
|
||||
"""Conecta las señales del motor."""
|
||||
self.engine.signals.job_created.connect(self._on_job_created)
|
||||
self.engine.signals.stats_updated.connect(self._on_stats_updated)
|
||||
self.engine.signals.config_loaded.connect(self._on_config_loaded)
|
||||
|
||||
|
||||
def closeEvent(self, event: QCloseEvent):
|
||||
"""Maneja el evento de cierre de ventana (minimizar a tray)."""
|
||||
if self.tray_icon.isVisible():
|
||||
"""Minimiza a bandeja al cerrar la ventana (no sale de la aplicación)."""
|
||||
if self._force_quit:
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self.tray_icon and self.tray_icon.isVisible():
|
||||
self.hide()
|
||||
event.ignore()
|
||||
app_logger.info("Ventana minimizada a bandeja del sistema")
|
||||
if not self._tray_hint_shown:
|
||||
self.tray_icon.showMessage(
|
||||
"CloudRestoreAS sigue en ejecución",
|
||||
"La aplicación está en la bandeja. Use clic derecho → Salir para cerrar.",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
4000,
|
||||
)
|
||||
self._tray_hint_shown = True
|
||||
else:
|
||||
event.accept()
|
||||
|
||||
|
||||
def _on_tray_activated(self, reason):
|
||||
"""Maneja la activación del icono de tray."""
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
@@ -184,35 +210,56 @@ class MainWindow(QMainWindow):
|
||||
self.hide()
|
||||
else:
|
||||
self._show_window()
|
||||
|
||||
|
||||
def _show_window(self):
|
||||
"""Muestra la ventana."""
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
|
||||
def _motor_status_text(self) -> str:
|
||||
if not self.engine.is_running():
|
||||
return "Detenido"
|
||||
if self.engine.is_paused():
|
||||
return "Pausado"
|
||||
return "Activo"
|
||||
|
||||
def _update_tray_status(self):
|
||||
"""Actualiza tooltip del icono de bandeja según estado del motor."""
|
||||
if not self.tray_icon:
|
||||
return
|
||||
status = self._motor_status_text()
|
||||
self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}")
|
||||
|
||||
def _start_engine(self):
|
||||
"""Inicia el motor."""
|
||||
self.engine.start()
|
||||
self.start_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(True)
|
||||
self.stop_action.setEnabled(True)
|
||||
self.resume_action.setEnabled(False)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor iniciado desde UI")
|
||||
|
||||
|
||||
def _pause_engine(self):
|
||||
"""Pausa el motor."""
|
||||
self.engine.pause()
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor pausado desde UI")
|
||||
|
||||
|
||||
def _resume_engine(self):
|
||||
"""Reanuda el motor."""
|
||||
self.engine.resume()
|
||||
self.resume_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor reanudado desde UI")
|
||||
|
||||
|
||||
def _stop_engine(self):
|
||||
"""Detiene el motor."""
|
||||
self.engine.stop()
|
||||
@@ -220,48 +267,53 @@ class MainWindow(QMainWindow):
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(False)
|
||||
self.start_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor detenido desde UI")
|
||||
|
||||
|
||||
def _scan_now(self):
|
||||
"""Fuerza un escaneo manual."""
|
||||
self.engine.scan_now()
|
||||
app_logger.info("Escaneo manual solicitado desde UI")
|
||||
|
||||
|
||||
def _update_stats(self):
|
||||
"""Actualiza las estadísticas."""
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
if self.engine.is_running():
|
||||
stats = self.engine.get_stats()
|
||||
self.dashboard_tab.update_stats(stats)
|
||||
|
||||
|
||||
def _on_job_created(self, job_id: str):
|
||||
"""Maneja la creación de un nuevo job."""
|
||||
self.jobs_tab.refresh_jobs()
|
||||
self._update_stats()
|
||||
|
||||
|
||||
def _on_stats_updated(self, stats: dict):
|
||||
"""Maneja la actualización de estadísticas."""
|
||||
self.dashboard_tab.update_stats(stats)
|
||||
|
||||
|
||||
def _on_config_loaded(self, config: dict):
|
||||
"""Maneja la carga de configuración."""
|
||||
self.config_tab.load_config(config)
|
||||
|
||||
|
||||
def _show_about(self):
|
||||
"""Muestra el diálogo Acerca de."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.about(
|
||||
self,
|
||||
"Acerca de CloudRestoreAS",
|
||||
"CloudRestoreAS v1.0.0\n\n"
|
||||
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
|
||||
"© 2026 Aduanasoft"
|
||||
"Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n"
|
||||
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"
|
||||
"© 2026 Aduanasoft",
|
||||
)
|
||||
|
||||
|
||||
def _quit_application(self):
|
||||
"""Sale de la aplicación completamente."""
|
||||
app_logger.info("Saliendo de la aplicación")
|
||||
self._force_quit = True
|
||||
self.engine.stop()
|
||||
self.tray_icon.hide()
|
||||
self.close()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
if self.tray_icon:
|
||||
self.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
|
||||
55
app/ui/tray_assets.py
Normal file
55
app/ui/tray_assets.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Icono de bandeja del sistema."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
|
||||
|
||||
_ASSETS_DIR = Path(__file__).resolve().parent.parent.parent / "packaging" / "assets"
|
||||
|
||||
|
||||
def _generated_tray_icon() -> QIcon:
|
||||
"""Icono simple en memoria si no hay archivo empaquetado."""
|
||||
size = 32
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setBrush(QColor(0, 120, 215))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(2, 2, size - 4, size - 4, 6, 6)
|
||||
painter.setPen(QColor(255, 255, 255))
|
||||
font = painter.font()
|
||||
font.setBold(True)
|
||||
font.setPointSize(14)
|
||||
painter.setFont(font)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "C")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def load_tray_icon() -> QIcon:
|
||||
"""Carga icono de bandeja desde assets empaquetados o genera uno por defecto."""
|
||||
candidates: list[Path] = []
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
base = Path(meipass)
|
||||
candidates.extend(
|
||||
[
|
||||
base / "packaging" / "assets" / "tray-icon.png",
|
||||
base / "packaging" / "assets" / "tray-icon.ico",
|
||||
]
|
||||
)
|
||||
candidates.extend(
|
||||
[
|
||||
_ASSETS_DIR / "tray-icon.png",
|
||||
_ASSETS_DIR / "tray-icon.ico",
|
||||
]
|
||||
)
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return _generated_tray_icon()
|
||||
@@ -1,59 +1,68 @@
|
||||
"""Utilidades de cifrado para passwords usando DPAPI en Windows."""
|
||||
"""Utilidades de cifrado para passwords (DPAPI en Windows, Fernet en Linux)."""
|
||||
|
||||
import base64
|
||||
import win32crypt
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..constants import DATA_DIR
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_SECRET_FILE = DATA_DIR / ".secret"
|
||||
|
||||
|
||||
def _get_fernet():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not _SECRET_FILE.exists():
|
||||
key = Fernet.generate_key()
|
||||
_SECRET_FILE.write_bytes(key)
|
||||
try:
|
||||
_SECRET_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
key = _SECRET_FILE.read_bytes()
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_password(password: str) -> str:
|
||||
"""
|
||||
Cifra una contraseña usando DPAPI de Windows.
|
||||
|
||||
Args:
|
||||
password: Contraseña en texto plano
|
||||
|
||||
Returns:
|
||||
Contraseña cifrada en base64
|
||||
"""
|
||||
"""Cifra una contraseña."""
|
||||
if not password:
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
encrypted_bytes = win32crypt.CryptProtectData(
|
||||
password.encode('utf-8'),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0
|
||||
)
|
||||
return base64.b64encode(encrypted_bytes).decode('ascii')
|
||||
if _IS_WINDOWS:
|
||||
import win32crypt
|
||||
|
||||
encrypted_bytes = win32crypt.CryptProtectData(
|
||||
password.encode("utf-8"), None, None, None, None, 0
|
||||
)
|
||||
return base64.b64encode(encrypted_bytes).decode("ascii")
|
||||
|
||||
fernet = _get_fernet()
|
||||
return fernet.encrypt(password.encode("utf-8")).decode("ascii")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error cifrando contraseña: {e}")
|
||||
raise RuntimeError(f"Error cifrando contraseña: {e}") from e
|
||||
|
||||
|
||||
def decrypt_password(encrypted_password: str) -> str:
|
||||
"""
|
||||
Descifra una contraseña usando DPAPI de Windows.
|
||||
|
||||
Args:
|
||||
encrypted_password: Contraseña cifrada en base64
|
||||
|
||||
Returns:
|
||||
Contraseña en texto plano
|
||||
"""
|
||||
"""Descifra una contraseña."""
|
||||
if not encrypted_password:
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
encrypted_bytes = base64.b64decode(encrypted_password)
|
||||
decrypted_bytes = win32crypt.CryptUnprotectData(
|
||||
encrypted_bytes,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0
|
||||
)[1]
|
||||
return decrypted_bytes.decode('utf-8')
|
||||
if _IS_WINDOWS:
|
||||
import win32crypt
|
||||
|
||||
encrypted_bytes = base64.b64decode(encrypted_password)
|
||||
decrypted_bytes = win32crypt.CryptUnprotectData(
|
||||
encrypted_bytes, None, None, None, 0
|
||||
)[1]
|
||||
return decrypted_bytes.decode("utf-8")
|
||||
|
||||
fernet = _get_fernet()
|
||||
return fernet.decrypt(encrypted_password.encode("ascii")).decode("utf-8")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error descifrando contraseña: {e}")
|
||||
raise RuntimeError(f"Error descifrando contraseña: {e}") from e
|
||||
|
||||
Reference in New Issue
Block a user