se agregaron nuevas caracteristicas
This commit is contained in:
@@ -17,9 +17,11 @@ CONFIG_FILE = AGENT_HOME / "config.json"
|
||||
|
||||
class LocalFolderJob(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
job_id: Optional[int] = None
|
||||
name: str
|
||||
source_path: str
|
||||
file_patterns: str = "*.bak,*.mdf"
|
||||
schedule_cron: str = "daily"
|
||||
schedule_interval_minutes: int = 60
|
||||
min_stable_seconds: int = 60
|
||||
is_active: bool = True
|
||||
|
||||
+139
-42
@@ -20,6 +20,36 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("OnEverAgent")
|
||||
|
||||
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
|
||||
if not schedule_str:
|
||||
return True
|
||||
if not last_run_str:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Try parsing ISO (from server) or standard YYYY-MM-DD HH:MM:SS (local)
|
||||
if "T" in last_run_str:
|
||||
last_run = datetime.fromisoformat(last_run_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
last_run = datetime.strptime(last_run_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - last_run
|
||||
|
||||
sched = schedule_str.lower().strip()
|
||||
if sched == "hourly":
|
||||
return delta.total_seconds() >= 3600
|
||||
elif sched == "daily":
|
||||
return delta.total_seconds() >= 86400
|
||||
elif sched == "weekly":
|
||||
return delta.total_seconds() >= 86400 * 7
|
||||
elif sched == "monthly":
|
||||
return delta.total_seconds() >= 86400 * 30
|
||||
else:
|
||||
return True
|
||||
|
||||
class AgentDaemon:
|
||||
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
||||
|
||||
@@ -35,6 +65,7 @@ class AgentDaemon:
|
||||
self.config = config or load_config()
|
||||
self.running = False
|
||||
self.uploader = ChunkUploader(self.config)
|
||||
self.last_server_jobs = []
|
||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
|
||||
@@ -100,53 +131,122 @@ class AgentDaemon:
|
||||
|
||||
time.sleep(30)
|
||||
|
||||
def _run_backup_cycle(self):
|
||||
def _run_backup_cycle(self, force: bool = False):
|
||||
self.config = load_config()
|
||||
self.uploader.config = self.config
|
||||
|
||||
# 1. Fetch server-assigned jobs
|
||||
# 1. Fetch server-assigned jobs and run bidirectional sync
|
||||
server_jobs = []
|
||||
sync_success = False
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
|
||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
server_jobs = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
self.last_server_jobs = server_jobs
|
||||
sync_success = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch server jobs: {e}")
|
||||
|
||||
# 2. Combine server jobs + user local folders
|
||||
all_jobs = []
|
||||
for sj in server_jobs:
|
||||
all_jobs.append({
|
||||
"job_id": sj.get("id"),
|
||||
"name": sj.get("name"),
|
||||
"source_path": sj.get("source_path"),
|
||||
"file_patterns": sj.get("file_patterns", "*.*"),
|
||||
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
|
||||
})
|
||||
config_changed = False
|
||||
|
||||
for lj in self.config.local_folders:
|
||||
if lj.is_active:
|
||||
all_jobs.append({
|
||||
"job_id": None,
|
||||
"local_job_id": lj.id,
|
||||
"name": lj.name,
|
||||
"source_path": lj.source_path,
|
||||
"file_patterns": lj.file_patterns,
|
||||
"min_stable_seconds": lj.min_stable_seconds
|
||||
})
|
||||
if sync_success:
|
||||
# A. Sync Server -> Local
|
||||
server_job_ids = {sj["id"] for sj in server_jobs}
|
||||
|
||||
# Remove local jobs that have a job_id but are not on the server anymore (deleted on server)
|
||||
local_jobs_to_keep = []
|
||||
for lj in self.config.local_folders:
|
||||
if lj.job_id is None:
|
||||
# New local job, keep it so we register it next
|
||||
local_jobs_to_keep.append(lj)
|
||||
elif lj.job_id in server_job_ids:
|
||||
# Keep it and update local properties from server
|
||||
sj = next(x for x in server_jobs if x["id"] == lj.job_id)
|
||||
lj.name = sj.get("name", lj.name)
|
||||
lj.source_path = sj.get("source_path", lj.source_path)
|
||||
lj.file_patterns = sj.get("file_patterns", lj.file_patterns)
|
||||
lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron)
|
||||
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
||||
|
||||
# Also sync last_run_at from server if available and newer
|
||||
if sj.get("last_run_at"):
|
||||
lj.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
||||
lj.last_status = sj.get("status", lj.last_status)
|
||||
|
||||
local_jobs_to_keep.append(lj)
|
||||
else:
|
||||
# Deleted on server, don't keep it
|
||||
config_changed = True
|
||||
|
||||
self.config.local_folders = local_jobs_to_keep
|
||||
|
||||
# 3. Process jobs
|
||||
for job in all_jobs:
|
||||
source_path = job["source_path"]
|
||||
file_patterns = job["file_patterns"]
|
||||
min_stable = job["min_stable_seconds"]
|
||||
job_name = job["name"]
|
||||
# Add server jobs that are missing locally
|
||||
local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None}
|
||||
for sj in server_jobs:
|
||||
if sj["id"] not in local_job_ids:
|
||||
new_job = LocalFolderJob(
|
||||
job_id=sj["id"],
|
||||
name=sj["name"],
|
||||
source_path=sj["source_path"],
|
||||
file_patterns=sj["file_patterns"],
|
||||
schedule_cron=sj["schedule_cron"],
|
||||
min_stable_seconds=sj["min_stable_time_seconds"],
|
||||
last_status=sj.get("status", "En espera")
|
||||
)
|
||||
if sj.get("last_run_at"):
|
||||
new_job.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
||||
self.config.local_folders.append(new_job)
|
||||
config_changed = True
|
||||
|
||||
# B. Sync Local -> Server (Register new local folders on the server)
|
||||
for lj in self.config.local_folders:
|
||||
if lj.job_id is None:
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
payload = {
|
||||
"name": lj.name,
|
||||
"source_path": lj.source_path,
|
||||
"file_patterns": lj.file_patterns,
|
||||
"schedule_cron": lj.schedule_cron,
|
||||
"min_stable_time_seconds": lj.min_stable_seconds
|
||||
}
|
||||
resp = httpx.post(f"{base_url}/api/jobs/agent/register", headers=self._get_headers(), json=payload, timeout=10.0)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
lj.job_id = data["id"]
|
||||
config_changed = True
|
||||
logger.info(f"Registered local job '{lj.name}' on server with ID {lj.job_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not register local job '{lj.name}' on server: {e}")
|
||||
|
||||
if config_changed:
|
||||
save_config(self.config)
|
||||
|
||||
# 2. Process active jobs
|
||||
for job in self.config.local_folders:
|
||||
if not job.is_active:
|
||||
continue
|
||||
|
||||
# Check if job is due or forced
|
||||
if not force and not is_job_due(job.schedule_cron, job.last_backup_at):
|
||||
continue
|
||||
|
||||
source_path = job.source_path
|
||||
file_patterns = job.file_patterns
|
||||
min_stable = job.min_stable_seconds
|
||||
job_name = job.name
|
||||
|
||||
# Skip if path does not exist
|
||||
if not Path(source_path).exists():
|
||||
logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.")
|
||||
continue
|
||||
|
||||
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
||||
files = scanner.scan()
|
||||
|
||||
# Track files successfully backed up in this run
|
||||
for filepath in files:
|
||||
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||
@@ -159,7 +259,6 @@ class AgentDaemon:
|
||||
file_size_bytes = filepath.stat().st_size
|
||||
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
||||
|
||||
# Single notification when the process starts
|
||||
if self.on_started:
|
||||
self.on_started(filepath.name, file_size_bytes)
|
||||
|
||||
@@ -170,24 +269,22 @@ class AgentDaemon:
|
||||
try:
|
||||
res = self.uploader.upload_file(
|
||||
filepath,
|
||||
job_id=job.get("job_id"),
|
||||
job_id=job.job_id,
|
||||
progress_callback=on_chunk_progress
|
||||
)
|
||||
logger.info(f"Successfully backed up {filepath.name}!")
|
||||
|
||||
# Update local folder last backup status
|
||||
if "local_job_id" in job:
|
||||
for folder in self.config.local_folders:
|
||||
if folder.id == job["local_job_id"]:
|
||||
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
folder.last_status = "Backup Exitoso"
|
||||
save_config(self.config)
|
||||
|
||||
# Single notification when the process finishes
|
||||
if self.on_completed:
|
||||
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
||||
job.last_status = "Error"
|
||||
save_config(self.config)
|
||||
if self.on_error:
|
||||
self.on_error(filepath.name, str(ex))
|
||||
|
||||
# Update job state in config after checking directory
|
||||
job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error"
|
||||
save_config(self.config)
|
||||
|
||||
@@ -4,11 +4,14 @@ import time
|
||||
import socket
|
||||
import platform
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
||||
logger = logging.getLogger("OnEverAgentGUI")
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
|
||||
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
@@ -233,11 +236,10 @@ class AddFolderDialog(QDialog):
|
||||
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.cmb_schedule = QComboBox()
|
||||
self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"])
|
||||
self.cmb_schedule.setCurrentText("daily")
|
||||
form.addRow("Planificación (Schedule):", self.cmb_schedule)
|
||||
|
||||
self.spin_stable = QSpinBox()
|
||||
self.spin_stable.setRange(10, 600)
|
||||
@@ -279,7 +281,7 @@ class AddFolderDialog(QDialog):
|
||||
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(),
|
||||
schedule_cron=self.cmb_schedule.currentText(),
|
||||
min_stable_seconds=self.spin_stable.value()
|
||||
)
|
||||
|
||||
@@ -311,6 +313,12 @@ class OnEverDriveMainWindow(QMainWindow):
|
||||
self._init_ui()
|
||||
self._init_tray()
|
||||
|
||||
# Timer to refresh folders list in GUI with server-assigned jobs periodically
|
||||
self.timer_refresh = QTimer(self)
|
||||
self.timer_refresh.setInterval(10000) # every 10 seconds
|
||||
self.timer_refresh.timeout.connect(self._refresh_folders_table)
|
||||
self.timer_refresh.start()
|
||||
|
||||
if self.config.device_id:
|
||||
self.daemon.start()
|
||||
|
||||
@@ -611,7 +619,13 @@ class OnEverDriveMainWindow(QMainWindow):
|
||||
self._build_tray_menu()
|
||||
|
||||
def _on_tray_activated(self, reason):
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||
# Safely convert to int to bypass PyQt6 enum comparison bugs
|
||||
try:
|
||||
val = int(reason)
|
||||
except Exception:
|
||||
val = reason.value if hasattr(reason, 'value') else reason
|
||||
|
||||
if val in (2, 3): # 2: DoubleClick, 3: Trigger
|
||||
self.showNormal()
|
||||
self.activateWindow()
|
||||
|
||||
@@ -650,13 +664,34 @@ class OnEverDriveMainWindow(QMainWindow):
|
||||
|
||||
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):
|
||||
server_jobs = getattr(self.daemon, "last_server_jobs", [])
|
||||
|
||||
# Combine local folders and server-assigned jobs
|
||||
total_rows = len(server_jobs) + len(self.config.local_folders)
|
||||
self.tbl_folders.setRowCount(total_rows)
|
||||
|
||||
# Show server jobs first
|
||||
row = 0
|
||||
for job in server_jobs:
|
||||
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.get("name", "")))
|
||||
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.get("source_path", "")))
|
||||
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.get("file_patterns", "")))
|
||||
self.tbl_folders.setItem(row, 3, QTableWidgetItem(job.get("schedule_cron", "")))
|
||||
|
||||
status_item = QTableWidgetItem("Sincronizado (Web)")
|
||||
status_item.setForeground(QColor("#34D399")) # Light green color in dark mode
|
||||
self.tbl_folders.setItem(row, 4, status_item)
|
||||
row += 1
|
||||
|
||||
# Then show local folders
|
||||
for job in 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"))
|
||||
self.tbl_folders.setItem(row, 3, QTableWidgetItem(getattr(job, "schedule_cron", "daily")))
|
||||
|
||||
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera (Local)"))
|
||||
row += 1
|
||||
|
||||
def _delete_selected_folder(self):
|
||||
row = self.tbl_folders.currentRow()
|
||||
@@ -665,11 +700,29 @@ class OnEverDriveMainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
job_name = self.tbl_folders.item(row, 0).text()
|
||||
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?")
|
||||
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el trabajo de backup '{job_name}' del Agente y del Servidor?")
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.config = load_config()
|
||||
if row < len(self.config.local_folders):
|
||||
self.config.local_folders.pop(row)
|
||||
path_val = self.tbl_folders.item(row, 1).text()
|
||||
matched_jobs = [f for f in self.config.local_folders if f.source_path == path_val]
|
||||
|
||||
if matched_jobs:
|
||||
job = matched_jobs[0]
|
||||
if job.job_id:
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
headers = {
|
||||
"X-Device-Id": self.config.device_id,
|
||||
"X-Device-Token": self.config.device_token
|
||||
}
|
||||
resp = httpx.delete(f"{base_url}/api/jobs/agent/{job.job_id}", headers=headers, timeout=5.0)
|
||||
if resp.status_code != 200:
|
||||
QMessageBox.warning(self, "Advertencia", f"No se pudo eliminar en el servidor: {resp.text}.\nSe eliminará localmente.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete job on server: {e}")
|
||||
QMessageBox.warning(self, "Advertencia", f"No se pudo contactar al servidor: {e}.\nSe eliminará localmente.")
|
||||
|
||||
self.config.local_folders = [f for f in self.config.local_folders if f.source_path != path_val]
|
||||
save_config(self.config)
|
||||
self._refresh_folders_table()
|
||||
self._update_header_status()
|
||||
@@ -735,7 +788,7 @@ class OnEverDriveMainWindow(QMainWindow):
|
||||
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()
|
||||
threading.Thread(target=lambda: self.daemon._run_backup_cycle(force=True), daemon=True).start()
|
||||
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
|
||||
|
||||
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
|
||||
|
||||
Reference in New Issue
Block a user