feat: implementar Fase 2 del roadmap (Modo Replica Exacta / Mirroring)
This commit is contained in:
@@ -21,6 +21,11 @@ class LocalFolderJob(BaseModel):
|
||||
name: str
|
||||
source_path: str
|
||||
file_patterns: str = "*.bak,*.mdf"
|
||||
exclude_patterns: str = ""
|
||||
skip_hidden: bool = False
|
||||
skip_system: bool = False
|
||||
skip_readonly: bool = False
|
||||
sync_deletions: bool = False
|
||||
schedule_cron: str = "daily"
|
||||
schedule_interval_minutes: int = 60
|
||||
min_stable_seconds: int = 60
|
||||
|
||||
@@ -45,12 +45,28 @@ def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
import platform
|
||||
import stat
|
||||
|
||||
class DirectoryScanner:
|
||||
"""Scans Windows source paths for files matching specific backup patterns."""
|
||||
|
||||
def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60):
|
||||
def __init__(
|
||||
self,
|
||||
source_path: str,
|
||||
file_patterns: str = "*.bak,*.mdf",
|
||||
exclude_patterns: str = "",
|
||||
skip_hidden: bool = False,
|
||||
skip_system: bool = False,
|
||||
skip_readonly: bool = False,
|
||||
min_stable_seconds: int = 60
|
||||
):
|
||||
self.source_path = Path(source_path).resolve()
|
||||
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
||||
self.exclude_patterns = [p.strip() for p in exclude_patterns.split(",") if p.strip()]
|
||||
self.skip_hidden = skip_hidden
|
||||
self.skip_system = skip_system
|
||||
self.skip_readonly = skip_readonly
|
||||
self.min_stable_seconds = min_stable_seconds
|
||||
|
||||
def scan(self) -> List[Path]:
|
||||
@@ -61,15 +77,20 @@ class DirectoryScanner:
|
||||
matched_files: List[Path] = []
|
||||
|
||||
if self.source_path.is_file():
|
||||
if self._matches_patterns(self.source_path.name):
|
||||
matched_files.append(self.source_path)
|
||||
if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name):
|
||||
if not self._should_skip_by_attributes(self.source_path):
|
||||
matched_files.append(self.source_path)
|
||||
return matched_files
|
||||
|
||||
for root, _, files in os.walk(self.source_path):
|
||||
for root, dirs, files in os.walk(self.source_path):
|
||||
# Prune excluded directories from search in-place
|
||||
dirs[:] = [d for d in dirs if not self._matches_exclude(d)]
|
||||
|
||||
for file in files:
|
||||
if self._matches_patterns(file):
|
||||
if self._matches_patterns(file) and not self._matches_exclude(file):
|
||||
full_path = Path(root) / file
|
||||
matched_files.append(full_path)
|
||||
if not self._should_skip_by_attributes(full_path):
|
||||
matched_files.append(full_path)
|
||||
|
||||
return matched_files
|
||||
|
||||
@@ -80,3 +101,29 @@ class DirectoryScanner:
|
||||
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _matches_exclude(self, filename: str) -> bool:
|
||||
for pattern in self.exclude_patterns:
|
||||
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _should_skip_by_attributes(self, filepath: Path) -> bool:
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
|
||||
try:
|
||||
st = filepath.stat()
|
||||
attrs = getattr(st, "st_file_attributes", 0)
|
||||
if not attrs:
|
||||
return False
|
||||
|
||||
if self.skip_readonly and (attrs & 0x01): # FILE_ATTRIBUTE_READONLY
|
||||
return True
|
||||
if self.skip_hidden and (attrs & 0x02): # FILE_ATTRIBUTE_HIDDEN
|
||||
return True
|
||||
if self.skip_system and (attrs & 0x04): # FILE_ATTRIBUTE_SYSTEM
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -167,6 +167,11 @@ class AgentDaemon:
|
||||
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.exclude_patterns = sj.get("exclude_patterns", lj.exclude_patterns)
|
||||
lj.skip_hidden = sj.get("skip_hidden", lj.skip_hidden)
|
||||
lj.skip_system = sj.get("skip_system", lj.skip_system)
|
||||
lj.skip_readonly = sj.get("skip_readonly", lj.skip_readonly)
|
||||
lj.sync_deletions = sj.get("sync_deletions", lj.sync_deletions)
|
||||
lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron)
|
||||
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
||||
|
||||
@@ -191,6 +196,11 @@ class AgentDaemon:
|
||||
name=sj["name"],
|
||||
source_path=sj["source_path"],
|
||||
file_patterns=sj["file_patterns"],
|
||||
exclude_patterns=sj.get("exclude_patterns", ""),
|
||||
skip_hidden=sj.get("skip_hidden", False),
|
||||
skip_system=sj.get("skip_system", False),
|
||||
skip_readonly=sj.get("skip_readonly", False),
|
||||
sync_deletions=sj.get("sync_deletions", False),
|
||||
schedule_cron=sj["schedule_cron"],
|
||||
min_stable_seconds=sj["min_stable_time_seconds"],
|
||||
last_status=sj.get("status", "En espera")
|
||||
@@ -209,6 +219,11 @@ class AgentDaemon:
|
||||
"name": lj.name,
|
||||
"source_path": lj.source_path,
|
||||
"file_patterns": lj.file_patterns,
|
||||
"exclude_patterns": lj.exclude_patterns,
|
||||
"skip_hidden": lj.skip_hidden,
|
||||
"skip_system": lj.skip_system,
|
||||
"skip_readonly": lj.skip_readonly,
|
||||
"sync_deletions": lj.sync_deletions,
|
||||
"schedule_cron": lj.schedule_cron,
|
||||
"min_stable_time_seconds": lj.min_stable_seconds
|
||||
}
|
||||
@@ -243,11 +258,26 @@ class AgentDaemon:
|
||||
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)
|
||||
scanner = DirectoryScanner(
|
||||
source_path=source_path,
|
||||
file_patterns=file_patterns,
|
||||
exclude_patterns=getattr(job, "exclude_patterns", ""),
|
||||
skip_hidden=getattr(job, "skip_hidden", False),
|
||||
skip_system=getattr(job, "skip_system", False),
|
||||
skip_readonly=getattr(job, "skip_readonly", False),
|
||||
min_stable_seconds=min_stable
|
||||
)
|
||||
files = scanner.scan()
|
||||
|
||||
# Track files successfully backed up in this run
|
||||
active_relative_paths = []
|
||||
for filepath in files:
|
||||
try:
|
||||
rel_p = str(filepath.relative_to(Path(source_path).resolve())).replace("\\", "/")
|
||||
active_relative_paths.append(rel_p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||
continue
|
||||
@@ -270,6 +300,7 @@ class AgentDaemon:
|
||||
res = self.uploader.upload_file(
|
||||
filepath,
|
||||
job_id=job.job_id,
|
||||
source_path=source_path,
|
||||
progress_callback=on_chunk_progress
|
||||
)
|
||||
logger.info(f"Successfully backed up {filepath.name}!")
|
||||
@@ -284,6 +315,27 @@ class AgentDaemon:
|
||||
if self.on_error:
|
||||
self.on_error(filepath.name, str(ex))
|
||||
|
||||
# Enforce mirror replica mode (purge orphans on server)
|
||||
if job.sync_deletions and job.job_id is not None:
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
purge_payload = {
|
||||
"active_relative_paths": active_relative_paths
|
||||
}
|
||||
resp = httpx.post(
|
||||
f"{base_url}/api/jobs/{job.job_id}/purge-orphans",
|
||||
headers=self._get_headers(),
|
||||
json=purge_payload,
|
||||
timeout=30.0
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
purge_res = resp.json()
|
||||
purged_count = purge_res.get("purged_count", 0)
|
||||
if purged_count > 0:
|
||||
logger.info(f"Purged {purged_count} orphan files from server for job '{job_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error purging orphan files from server: {e}")
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -25,6 +25,7 @@ class ChunkUploader:
|
||||
self,
|
||||
filepath: Path,
|
||||
job_id: Optional[int] = None,
|
||||
source_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
@@ -37,6 +38,14 @@ class ChunkUploader:
|
||||
full_sha256 = chunker.full_sha256
|
||||
filename = filepath.name
|
||||
|
||||
client_relative_path = filename
|
||||
if source_path:
|
||||
try:
|
||||
resolved_src = Path(source_path).resolve()
|
||||
client_relative_path = str(filepath.relative_to(resolved_src)).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = self._get_headers()
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
|
||||
@@ -44,6 +53,7 @@ class ChunkUploader:
|
||||
# 1. Initialize or resume upload session
|
||||
init_payload = {
|
||||
"filename": filename,
|
||||
"client_relative_path": client_relative_path,
|
||||
"file_size": chunker.file_size,
|
||||
"sha256": full_sha256,
|
||||
"chunk_size": chunker.chunk_size,
|
||||
|
||||
@@ -210,7 +210,7 @@ class AddFolderDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
|
||||
self.resize(500, 320)
|
||||
self.resize(500, 470)
|
||||
self.setStyleSheet(DARK_QSS)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -236,6 +236,10 @@ class AddFolderDialog(QDialog):
|
||||
self.txt_patterns.setText("*.bak,*.mdf")
|
||||
form.addRow("Filtros de archivo:", self.txt_patterns)
|
||||
|
||||
self.txt_exclude_patterns = QLineEdit()
|
||||
self.txt_exclude_patterns.setPlaceholderText("Ej: *.tmp, *.log, node_modules")
|
||||
form.addRow("Filtros de exclusión:", self.txt_exclude_patterns)
|
||||
|
||||
self.cmb_schedule = QComboBox()
|
||||
self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"])
|
||||
self.cmb_schedule.setCurrentText("daily")
|
||||
@@ -247,6 +251,24 @@ class AddFolderDialog(QDialog):
|
||||
self.spin_stable.setSuffix(" seg")
|
||||
form.addRow("Estabilidad de archivo (Locks):", self.spin_stable)
|
||||
|
||||
# File attribute skip checkboxes (Karen's Replicator style)
|
||||
self.chk_skip_hidden = QCheckBox("Omitir archivos ocultos (Hidden)")
|
||||
self.chk_skip_hidden.setChecked(False)
|
||||
form.addRow("", self.chk_skip_hidden)
|
||||
|
||||
self.chk_skip_system = QCheckBox("Omitir archivos de sistema (System)")
|
||||
self.chk_skip_system.setChecked(False)
|
||||
form.addRow("", self.chk_skip_system)
|
||||
|
||||
self.chk_skip_readonly = QCheckBox("Omitir archivos de solo lectura (Read-only)")
|
||||
self.chk_skip_readonly.setChecked(False)
|
||||
form.addRow("", self.chk_skip_readonly)
|
||||
|
||||
# Mirror sync checkbox (Phase 2)
|
||||
self.chk_sync_deletions = QCheckBox("Modo Réplica Exacta (Eliminar en destino si no existe en origen)")
|
||||
self.chk_sync_deletions.setChecked(False)
|
||||
form.addRow("", self.chk_sync_deletions)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
@@ -281,6 +303,11 @@ class AddFolderDialog(QDialog):
|
||||
name=self.txt_name.text().strip(),
|
||||
source_path=self.txt_path.text().strip(),
|
||||
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
||||
exclude_patterns=self.txt_exclude_patterns.text().strip(),
|
||||
skip_hidden=self.chk_skip_hidden.isChecked(),
|
||||
skip_system=self.chk_skip_system.isChecked(),
|
||||
skip_readonly=self.chk_skip_readonly.isChecked(),
|
||||
sync_deletions=self.chk_sync_deletions.isChecked(),
|
||||
schedule_cron=self.cmb_schedule.currentText(),
|
||||
min_stable_seconds=self.spin_stable.value()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user