feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import math
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
def compute_file_sha256(filepath: Path, buffer_size: int = 1024 * 1024) -> str:
|
||||
"""Calculates full SHA-256 checksum of a file using streaming buffers to avoid memory overhead."""
|
||||
hasher = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(buffer_size)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
class FileChunker:
|
||||
"""Handles splitting large files into discrete chunks and calculating block hashes."""
|
||||
|
||||
def __init__(self, filepath: Path, chunk_size: int = 4 * 1024 * 1024):
|
||||
self.filepath = Path(filepath).resolve()
|
||||
if not self.filepath.exists():
|
||||
raise FileNotFoundError(f"File {filepath} not found")
|
||||
|
||||
self.file_size = self.filepath.stat().st_size
|
||||
self.chunk_size = chunk_size
|
||||
self.total_chunks = max(1, math.ceil(self.file_size / self.chunk_size))
|
||||
self._cached_sha256 = None
|
||||
|
||||
@property
|
||||
def full_sha256(self) -> str:
|
||||
if self._cached_sha256 is None:
|
||||
self._cached_sha256 = compute_file_sha256(self.filepath)
|
||||
return self._cached_sha256
|
||||
|
||||
def get_chunk(self, chunk_index: int) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Reads the bytes for chunk `chunk_index` and returns (chunk_data, chunk_sha256).
|
||||
"""
|
||||
if chunk_index < 0 or chunk_index >= self.total_chunks:
|
||||
raise IndexError(f"Chunk index {chunk_index} out of bounds (total: {self.total_chunks})")
|
||||
|
||||
offset = chunk_index * self.chunk_size
|
||||
with open(self.filepath, "rb") as f:
|
||||
f.seek(offset)
|
||||
data = f.read(self.chunk_size)
|
||||
|
||||
chunk_sha256 = hashlib.sha256(data).hexdigest()
|
||||
return data, chunk_sha256
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AGENT_HOME = Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / "OnEverDrive"
|
||||
if not AGENT_HOME.exists():
|
||||
try:
|
||||
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
AGENT_HOME = Path(__file__).resolve().parent.parent / "data"
|
||||
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CONFIG_FILE = AGENT_HOME / "config.json"
|
||||
|
||||
class LocalFolderJob(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
name: str
|
||||
source_path: str
|
||||
file_patterns: str = "*.bak,*.mdf"
|
||||
schedule_interval_minutes: int = 60
|
||||
min_stable_seconds: int = 60
|
||||
is_active: bool = True
|
||||
last_backup_at: Optional[str] = None
|
||||
last_status: Optional[str] = "En espera"
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
server_url: str = "http://127.0.0.1:8000"
|
||||
client_code: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
device_token: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
chunk_size: int = 4 * 1024 * 1024 # 4 MB
|
||||
heartbeat_interval_seconds: int = 30
|
||||
min_stable_time_seconds: int = 60
|
||||
log_level: str = "INFO"
|
||||
local_folders: List[LocalFolderJob] = []
|
||||
|
||||
# Notification preferences (configurable from GUI and Tray)
|
||||
enable_notifications: bool = True
|
||||
notify_on_start: bool = True
|
||||
notify_on_complete: bool = True
|
||||
notify_on_error: bool = True
|
||||
|
||||
def load_config() -> AgentConfig:
|
||||
"""Loads agent configuration from disk or returns default configuration."""
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return AgentConfig(**data)
|
||||
except Exception:
|
||||
pass
|
||||
return AgentConfig()
|
||||
|
||||
def save_config(config: AgentConfig) -> None:
|
||||
"""Persists agent configuration to disk."""
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(config.model_dump(), f, indent=2)
|
||||
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import time
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
def is_file_locked(filepath: Path) -> bool:
|
||||
"""
|
||||
Checks if a file is locked exclusively by another process (e.g. SQL Server writing .bak).
|
||||
Attempts to open the file with read-shared permissions.
|
||||
"""
|
||||
if not filepath.exists() or not filepath.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
# On Windows, try opening in append/read mode to detect exclusive write lock
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
return False
|
||||
except (PermissionError, IOError, OSError):
|
||||
return True
|
||||
|
||||
def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval_seconds: float = 0.5) -> bool:
|
||||
"""
|
||||
Ensures that a file is not actively growing or being modified.
|
||||
Verifies that modification timestamp and size are stable.
|
||||
"""
|
||||
if is_file_locked(filepath):
|
||||
return False
|
||||
|
||||
try:
|
||||
stat_initial = filepath.stat()
|
||||
initial_size = stat_initial.st_size
|
||||
initial_mtime = stat_initial.st_mtime
|
||||
|
||||
# Check if the file was modified very recently compared to current time
|
||||
current_time = time.time()
|
||||
if (current_time - initial_mtime) < min_stable_seconds:
|
||||
# File was modified less than min_stable_seconds ago; perform sample check
|
||||
time.sleep(sample_interval_seconds)
|
||||
stat_second = filepath.stat()
|
||||
if stat_second.st_size != initial_size or stat_second.st_mtime != initial_mtime:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
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):
|
||||
self.source_path = Path(source_path).resolve()
|
||||
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
||||
self.min_stable_seconds = min_stable_seconds
|
||||
|
||||
def scan(self) -> List[Path]:
|
||||
"""Returns list of all matching files that are stable and ready for backup."""
|
||||
if not self.source_path.exists():
|
||||
return []
|
||||
|
||||
matched_files: List[Path] = []
|
||||
|
||||
if self.source_path.is_file():
|
||||
if self._matches_patterns(self.source_path.name):
|
||||
matched_files.append(self.source_path)
|
||||
return matched_files
|
||||
|
||||
for root, _, files in os.walk(self.source_path):
|
||||
for file in files:
|
||||
if self._matches_patterns(file):
|
||||
full_path = Path(root) / file
|
||||
matched_files.append(full_path)
|
||||
|
||||
return matched_files
|
||||
|
||||
def _matches_patterns(self, filename: str) -> bool:
|
||||
if not self.patterns or "*" in self.patterns:
|
||||
return True
|
||||
for pattern in self.patterns:
|
||||
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,193 @@
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from agent.config import AgentConfig, load_config, save_config, LocalFolderJob
|
||||
from agent.scanner import DirectoryScanner, is_file_stable
|
||||
from agent.chunker import compute_file_sha256
|
||||
from agent.uploader import ChunkUploader
|
||||
from agent.state_db import state_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("OnEverAgent")
|
||||
|
||||
class AgentDaemon:
|
||||
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[AgentConfig] = None,
|
||||
on_started: Optional[Callable[[str, int], None]] = None,
|
||||
on_progress: Optional[Callable[[str, int, int, float], None]] = None,
|
||||
on_completed: Optional[Callable[[str, str, int], None]] = None,
|
||||
on_error: Optional[Callable[[str, str], None]] = None,
|
||||
on_status: Optional[Callable[[str, str], None]] = None
|
||||
):
|
||||
self.config = config or load_config()
|
||||
self.running = False
|
||||
self.uploader = ChunkUploader(self.config)
|
||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Event callbacks
|
||||
self.on_started = on_started
|
||||
self.on_progress = on_progress
|
||||
self.on_completed = on_completed
|
||||
self.on_error = on_error
|
||||
self.on_status = on_status
|
||||
|
||||
def start(self):
|
||||
self.config = load_config()
|
||||
if not self.config.device_id or not self.config.device_token:
|
||||
logger.warning("Agent is not registered yet. Waiting for registration.")
|
||||
if self.on_status:
|
||||
self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})")
|
||||
logger.info(f"Target Server: {self.config.server_url}")
|
||||
|
||||
if self.on_status:
|
||||
self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})")
|
||||
|
||||
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
||||
self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True)
|
||||
|
||||
self._heartbeat_thread.start()
|
||||
self._worker_thread.start()
|
||||
|
||||
def stop(self):
|
||||
logger.info("Stopping agent daemon...")
|
||||
self.running = False
|
||||
if self.on_status:
|
||||
self.on_status("PAUSED", "Servicio en pausa.")
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
"X-Device-Id": self.config.device_id,
|
||||
"X-Device-Token": self.config.device_token
|
||||
}
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
while self.running:
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
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:
|
||||
logger.debug("Heartbeat acknowledged by server.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Heartbeat failed: {str(ex)}")
|
||||
|
||||
time.sleep(self.config.heartbeat_interval_seconds)
|
||||
|
||||
def _backup_worker_loop(self):
|
||||
while self.running:
|
||||
try:
|
||||
self._run_backup_cycle()
|
||||
except Exception as ex:
|
||||
logger.error(f"Error during backup cycle: {str(ex)}")
|
||||
|
||||
time.sleep(30)
|
||||
|
||||
def _run_backup_cycle(self):
|
||||
self.config = load_config()
|
||||
self.uploader.config = self.config
|
||||
|
||||
# 1. Fetch server-assigned jobs
|
||||
server_jobs = []
|
||||
try:
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
server_jobs = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
# 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"]
|
||||
|
||||
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
||||
files = scanner.scan()
|
||||
|
||||
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.")
|
||||
continue
|
||||
|
||||
current_sha = compute_file_sha256(filepath)
|
||||
if state_db.is_file_already_backed_up(str(filepath), current_sha):
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
def on_chunk_progress(done, total, pct):
|
||||
if self.on_progress:
|
||||
self.on_progress(filepath.name, done, total, pct)
|
||||
|
||||
try:
|
||||
res = self.uploader.upload_file(
|
||||
filepath,
|
||||
job_id=job.get("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)}")
|
||||
if self.on_error:
|
||||
self.on_error(filepath.name, str(ex))
|
||||
@@ -0,0 +1,102 @@
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from agent.config import AGENT_HOME
|
||||
|
||||
STATE_DB_PATH = AGENT_HOME / "agent_state.db"
|
||||
|
||||
class StateDatabase:
|
||||
"""Local SQLite database for agent offline resiliency, session resume tracking and file caching."""
|
||||
|
||||
def __init__(self, db_path: Path = STATE_DB_PATH):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(str(self.db_path))
|
||||
|
||||
def _init_db(self):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS upload_sessions (
|
||||
filepath TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
session_code TEXT NOT NULL,
|
||||
total_chunks INTEGER NOT NULL,
|
||||
chunk_size INTEGER NOT NULL,
|
||||
completed_chunks INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
last_updated TIMESTAMP NOT NULL
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS completed_files (
|
||||
filepath TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
job_id INTEGER,
|
||||
last_backup_time TIMESTAMP NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def save_session(
|
||||
self,
|
||||
filepath: str,
|
||||
sha256: str,
|
||||
session_code: str,
|
||||
total_chunks: int,
|
||||
chunk_size: int,
|
||||
status: str = "UPLOADING"
|
||||
):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO upload_sessions
|
||||
(filepath, sha256, session_code, total_chunks, chunk_size, status, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (filepath, sha256, session_code, total_chunks, chunk_size, status, now))
|
||||
conn.commit()
|
||||
|
||||
def get_session(self, filepath: str) -> Optional[Dict[str, Any]]:
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT filepath, sha256, session_code, total_chunks, chunk_size, completed_chunks, status FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
"filepath": row[0],
|
||||
"sha256": row[1],
|
||||
"session_code": row[2],
|
||||
"total_chunks": row[3],
|
||||
"chunk_size": row[4],
|
||||
"completed_chunks": row[5],
|
||||
"status": row[6]
|
||||
}
|
||||
return None
|
||||
|
||||
def mark_session_completed(self, filepath: str, sha256: str, file_size: int, job_id: Optional[int] = None):
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cursor.execute("DELETE FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO completed_files (filepath, sha256, file_size, job_id, last_backup_time)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (filepath, sha256, file_size, job_id, now))
|
||||
conn.commit()
|
||||
|
||||
def is_file_already_backed_up(self, filepath: str, current_sha256: str) -> bool:
|
||||
with self._get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT sha256 FROM completed_files WHERE filepath = ?", (filepath,))
|
||||
row = cursor.fetchone()
|
||||
if row and row[0] == current_sha256:
|
||||
return True
|
||||
return False
|
||||
|
||||
state_db = StateDatabase()
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from agent.config import AgentConfig, load_config
|
||||
from agent.chunker import FileChunker
|
||||
from agent.state_db import state_db
|
||||
|
||||
class ChunkUploader:
|
||||
"""HTTP/HTTPS Chunk transfer client with automatic resumption and retry backoff."""
|
||||
|
||||
def __init__(self, config: Optional[AgentConfig] = None):
|
||||
self.config = config or load_config()
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
if not self.config.device_id or not self.config.device_token:
|
||||
raise ValueError("Agent is not registered. Run 'agent_cli.py register' first.")
|
||||
return {
|
||||
"X-Device-Id": self.config.device_id,
|
||||
"X-Device-Token": self.config.device_token
|
||||
}
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
filepath: Path,
|
||||
job_id: Optional[int] = None,
|
||||
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Uploads a file chunk by chunk. If interrupted, querying the server will return
|
||||
already received chunks, allowing immediate resumption without re-uploading completed parts.
|
||||
"""
|
||||
filepath = Path(filepath).resolve()
|
||||
chunker = FileChunker(filepath=filepath, chunk_size=self.config.chunk_size)
|
||||
full_sha256 = chunker.full_sha256
|
||||
filename = filepath.name
|
||||
|
||||
headers = self._get_headers()
|
||||
base_url = self.config.server_url.rstrip("/")
|
||||
|
||||
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
|
||||
# 1. Initialize or resume upload session
|
||||
init_payload = {
|
||||
"filename": filename,
|
||||
"file_size": chunker.file_size,
|
||||
"sha256": full_sha256,
|
||||
"chunk_size": chunker.chunk_size,
|
||||
"job_id": job_id
|
||||
}
|
||||
|
||||
resp = client.post("/api/upload/session", json=init_payload)
|
||||
resp.raise_for_status()
|
||||
session_data = resp.json()
|
||||
|
||||
session_code = session_data["session_code"]
|
||||
total_chunks = session_data["total_chunks"]
|
||||
received_chunks = set(session_data.get("received_chunks", []))
|
||||
|
||||
state_db.save_session(
|
||||
filepath=str(filepath),
|
||||
sha256=full_sha256,
|
||||
session_code=session_code,
|
||||
total_chunks=total_chunks,
|
||||
chunk_size=chunker.chunk_size,
|
||||
status="UPLOADING"
|
||||
)
|
||||
|
||||
# 2. Upload missing chunks
|
||||
missing_chunks = [i for i in range(total_chunks) if i not in received_chunks]
|
||||
|
||||
for idx in missing_chunks:
|
||||
chunk_bytes, chunk_hash = chunker.get_chunk(idx)
|
||||
|
||||
# Retry loop with exponential backoff for network resilience
|
||||
success = False
|
||||
attempt = 0
|
||||
while not success and attempt < max_retries:
|
||||
try:
|
||||
chunk_headers = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Chunk-Index": str(idx),
|
||||
"X-Chunk-SHA256": chunk_hash
|
||||
}
|
||||
c_resp = client.post(
|
||||
f"/api/upload/{session_code}/chunk?chunk_index={idx}&chunk_sha256={chunk_hash}",
|
||||
content=chunk_bytes,
|
||||
headers=chunk_headers
|
||||
)
|
||||
c_resp.raise_for_status()
|
||||
success = True
|
||||
except Exception as ex:
|
||||
attempt += 1
|
||||
if attempt >= max_retries:
|
||||
raise ConnectionError(f"Failed to upload chunk {idx} after {max_retries} attempts: {str(ex)}")
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
received_chunks.add(idx)
|
||||
if progress_callback:
|
||||
pct = round((len(received_chunks) / total_chunks) * 100, 2)
|
||||
progress_callback(len(received_chunks), total_chunks, pct)
|
||||
|
||||
# 3. Complete and verify full file assembly on server
|
||||
complete_resp = client.post(f"/api/upload/{session_code}/complete")
|
||||
complete_resp.raise_for_status()
|
||||
result = complete_resp.json()
|
||||
|
||||
# 4. Update local state database
|
||||
state_db.mark_session_completed(
|
||||
filepath=str(filepath),
|
||||
sha256=full_sha256,
|
||||
file_size=chunker.file_size,
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,817 @@
|
||||
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()
|
||||
@@ -0,0 +1,190 @@
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import socket
|
||||
import platform
|
||||
import time
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from colorama import init, Fore, Style
|
||||
|
||||
# Add agent directory to sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from agent.config import load_config, save_config, AgentConfig
|
||||
from agent.uploader import ChunkUploader
|
||||
from agent.service import AgentDaemon
|
||||
|
||||
init(autoreset=True)
|
||||
|
||||
def print_banner():
|
||||
print(Fore.CYAN + Style.BRIGHT + """
|
||||
+------------------------------------------------------------------+
|
||||
| ONEVER DRIVE - WINDOWS AGENT CLI |
|
||||
| Enterprise Resilient Chunk Backup Engine for Windows |
|
||||
+------------------------------------------------------------------+
|
||||
""")
|
||||
|
||||
def cmd_register(args):
|
||||
print_banner()
|
||||
server_url = args.server.rstrip("/")
|
||||
code = args.code.strip().upper()
|
||||
|
||||
hostname = socket.gethostname()
|
||||
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
|
||||
|
||||
print(Fore.YELLOW + f"Connecting to {server_url} with registration code: {code}...")
|
||||
|
||||
payload = {
|
||||
"registration_code": code,
|
||||
"name": args.name or hostname,
|
||||
"hostname": hostname,
|
||||
"os_info": os_info,
|
||||
"agent_version": "1.0.0"
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(f"{server_url}/api/clients/register", json=payload)
|
||||
if resp.status_code != 200:
|
||||
print(Fore.RED + f"Registration failed ({resp.status_code}): {resp.text}")
|
||||
sys.exit(1)
|
||||
|
||||
data = resp.json()
|
||||
config = load_config()
|
||||
config.server_url = server_url
|
||||
config.client_code = data["client_code"]
|
||||
config.device_id = data["device_id"]
|
||||
config.device_token = data["device_token"]
|
||||
config.client_name = data["name"]
|
||||
save_config(config)
|
||||
|
||||
print(Fore.GREEN + Style.BRIGHT + "\n[+] Agent registered successfully!")
|
||||
print(Fore.WHITE + f" Client Code : {data['client_code']}")
|
||||
print(Fore.WHITE + f" Device ID : {data['device_id']}")
|
||||
print(Fore.WHITE + f" Client Name : {data['name']}")
|
||||
print(Fore.CYAN + "\nYou can now start the agent daemon or run manual backups.")
|
||||
|
||||
except Exception as ex:
|
||||
print(Fore.RED + f"Error connecting to server: {str(ex)}")
|
||||
sys.exit(1)
|
||||
|
||||
def cmd_status(args):
|
||||
print_banner()
|
||||
config = load_config()
|
||||
if not config.device_id:
|
||||
print(Fore.YELLOW + "Agent is NOT registered yet. Run 'agent_cli.py register' first.")
|
||||
return
|
||||
|
||||
print(Fore.GREEN + "[*] Agent Configuration:")
|
||||
print(f" Server URL : {config.server_url}")
|
||||
print(f" Client Code : {config.client_code}")
|
||||
print(f" Device ID : {config.device_id}")
|
||||
print(f" Client Name : {config.client_name}")
|
||||
print(f" Chunk Size : {config.chunk_size / (1024*1024):.1f} MB")
|
||||
|
||||
print(Fore.CYAN + "\n[*] Testing connection to server...")
|
||||
try:
|
||||
headers = {
|
||||
"X-Device-Id": config.device_id,
|
||||
"X-Device-Token": config.device_token
|
||||
}
|
||||
with httpx.Client(base_url=config.server_url, headers=headers, timeout=10.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
jobs = resp.json()
|
||||
print(Fore.GREEN + f" Connection OK. Assigned jobs count: {len(jobs)}")
|
||||
for j in jobs:
|
||||
print(Fore.WHITE + f" - [{j['job_code']}] {j['name']} ({j['source_path']} | {j['file_patterns']})")
|
||||
else:
|
||||
print(Fore.RED + f" Server returned {resp.status_code}: {resp.text}")
|
||||
except Exception as ex:
|
||||
print(Fore.RED + f" Connection failed: {str(ex)}")
|
||||
|
||||
def cmd_backup(args):
|
||||
print_banner()
|
||||
filepath = Path(args.file).resolve()
|
||||
if not filepath.exists():
|
||||
print(Fore.RED + f"File not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
config = load_config()
|
||||
if not config.device_id:
|
||||
print(Fore.RED + "Agent is not registered. Run registration first.")
|
||||
sys.exit(1)
|
||||
|
||||
print(Fore.CYAN + f"[*] Initiating chunked backup for: {filepath.name}")
|
||||
print(f" File size : {filepath.stat().st_size / (1024*1024):.2f} MB")
|
||||
print(f" Chunk size : {config.chunk_size / (1024*1024):.1f} MB")
|
||||
|
||||
uploader = ChunkUploader(config)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
def print_progress(received, total, pct):
|
||||
bar_len = 30
|
||||
filled = int(bar_len * (pct / 100))
|
||||
bar = "#" * filled + "-" * (bar_len - filled)
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}Progress: [{bar}] {pct:.1f}% ({received}/{total} chunks)")
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
result = uploader.upload_file(filepath, job_id=args.job, progress_callback=print_progress)
|
||||
elapsed = max(0.01, time.time() - start_time)
|
||||
mb = filepath.stat().st_size / (1024 * 1024)
|
||||
speed = mb / elapsed
|
||||
|
||||
print(Fore.GREEN + Style.BRIGHT + f"\n\n[+] Backup Complete & Verified!")
|
||||
print(Fore.WHITE + f" Remote Path : {result.get('relative_path')}")
|
||||
print(Fore.WHITE + f" SHA-256 : {result.get('sha256')}")
|
||||
print(Fore.WHITE + f" Transfer : {mb:.2f} MB in {elapsed:.2f}s ({speed:.2f} MB/s)")
|
||||
except Exception as ex:
|
||||
print(Fore.RED + f"\n[-] Backup failed: {str(ex)}")
|
||||
sys.exit(1)
|
||||
|
||||
def cmd_daemon(args):
|
||||
print_banner()
|
||||
config = load_config()
|
||||
daemon = AgentDaemon(config)
|
||||
daemon.start()
|
||||
print(Fore.GREEN + "[*] Agent daemon running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
daemon.stop()
|
||||
print(Fore.YELLOW + "\nAgent stopped.")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OnEver Drive Windows Agent CLI")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# Register
|
||||
p_reg = subparsers.add_parser("register", help="Register agent with central server")
|
||||
p_reg.add_argument("--server", required=True, help="Server URL (e.g. http://192.168.1.100:8000)")
|
||||
p_reg.add_argument("--code", required=True, help="Registration code (e.g. OED-A1B2-C3D4)")
|
||||
p_reg.add_argument("--name", help="Custom name for this client machine")
|
||||
p_reg.set_defaults(func=cmd_register)
|
||||
|
||||
# Status
|
||||
p_stat = subparsers.add_parser("status", help="Show current agent status and server connectivity")
|
||||
p_stat.set_defaults(func=cmd_status)
|
||||
|
||||
# Backup
|
||||
p_bak = subparsers.add_parser("backup", help="Perform manual chunked backup of a file")
|
||||
p_bak.add_argument("--file", required=True, help="Path to file to back up")
|
||||
p_bak.add_argument("--job", type=int, help="Optional Backup Job ID")
|
||||
p_bak.set_defaults(func=cmd_backup)
|
||||
|
||||
# Daemon
|
||||
p_daemon = subparsers.add_parser("daemon", help="Run the background worker loop in foreground")
|
||||
p_daemon.set_defaults(func=cmd_daemon)
|
||||
|
||||
args = parser.parse_args()
|
||||
if hasattr(args, "func"):
|
||||
args.func(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,301 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
# Add agent root to sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from agent.config import load_config, save_config, AgentConfig
|
||||
from agent.uploader import ChunkUploader
|
||||
from agent.chunker import compute_file_sha256
|
||||
|
||||
class AgentGuiApp:
|
||||
"""Lightweight Windows GUI control panel for OnEver Drive."""
|
||||
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.root.title("OnEver Drive — Agente Windows")
|
||||
self.root.geometry("640x520")
|
||||
self.root.resizable(False, False)
|
||||
|
||||
# Style configuration
|
||||
self.config = load_config()
|
||||
self._apply_dark_theme()
|
||||
|
||||
# Build UI layout
|
||||
self._build_header()
|
||||
self._build_tabs()
|
||||
|
||||
self._refresh_status()
|
||||
|
||||
def _apply_dark_theme(self):
|
||||
self.root.configure(bg="#0F172A")
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
|
||||
# Configure colors
|
||||
style.configure("TNotebook", background="#0F172A", borderwidth=0)
|
||||
style.configure("TNotebook.Tab", background="#1E293B", foreground="#94A3B8", padding=[16, 8], font=("Segoe UI", 9, "bold"))
|
||||
style.map("TNotebook.Tab", background=[("selected", "#06B6D4")], foreground=[("selected", "#FFFFFF")])
|
||||
|
||||
style.configure("TFrame", background="#0F172A")
|
||||
style.configure("Card.TFrame", background="#1E293B", relief="flat")
|
||||
style.configure("TLabel", background="#0F172A", foreground="#F8FAFC", font=("Segoe UI", 9))
|
||||
style.configure("Card.TLabel", background="#1E293B", foreground="#F8FAFC", font=("Segoe UI", 9))
|
||||
style.configure("Dim.TLabel", background="#1E293B", foreground="#94A3B8", font=("Segoe UI", 8))
|
||||
style.configure("Header.TLabel", background="#0F172A", foreground="#FFFFFF", font=("Segoe UI", 12, "bold"))
|
||||
|
||||
style.configure("Primary.TButton", background="#06B6D4", foreground="#FFFFFF", font=("Segoe UI", 9, "bold"), borderwidth=0, padding=6)
|
||||
style.map("Primary.TButton", background=[("active", "#0891B2")])
|
||||
|
||||
style.configure("Secondary.TButton", background="#334155", foreground="#FFFFFF", font=("Segoe UI", 9), borderwidth=0, padding=6)
|
||||
style.map("Secondary.TButton", background=[("active", "#475569")])
|
||||
|
||||
style.configure("TProgressbar", thickness=10, background="#06B6D4", troughcolor="#334155", borderwidth=0)
|
||||
|
||||
def _build_header(self):
|
||||
header_frame = ttk.Frame(self.root, padding=16)
|
||||
header_frame.pack(fill="x")
|
||||
|
||||
title_lbl = ttk.Label(header_frame, text="OnEver Drive — Agente Windows", style="Header.TLabel")
|
||||
title_lbl.pack(side="left")
|
||||
|
||||
self.status_badge = tk.Label(header_frame, text="● En Línea", bg="#065F46", fg="#34D399", font=("Segoe UI", 8, "bold"), padx=8, pady=3)
|
||||
self.status_badge.pack(side="right")
|
||||
|
||||
def _build_tabs(self):
|
||||
self.notebook = ttk.Notebook(self.root)
|
||||
self.notebook.pack(fill="both", expand=True, padx=16, pady=(0, 16))
|
||||
|
||||
# Tab 1: Estado y Dispositivo
|
||||
self.tab_status = ttk.Frame(self.notebook, padding=16)
|
||||
self.notebook.add(self.tab_status, text="Estado")
|
||||
self._build_tab_status()
|
||||
|
||||
# Tab 2: Trabajos de Backup
|
||||
self.tab_jobs = ttk.Frame(self.notebook, padding=16)
|
||||
self.notebook.add(self.tab_jobs, text="Trabajos Asignados")
|
||||
self._build_tab_jobs()
|
||||
|
||||
# Tab 3: Respaldo Manual
|
||||
self.tab_manual = ttk.Frame(self.notebook, padding=16)
|
||||
self.notebook.add(self.tab_manual, text="Backup Manual")
|
||||
self._build_tab_manual()
|
||||
|
||||
# Tab 4: Registro / Configuración
|
||||
self.tab_config = ttk.Frame(self.notebook, padding=16)
|
||||
self.notebook.add(self.tab_config, text="Configuración")
|
||||
self._build_tab_config()
|
||||
|
||||
def _build_tab_status(self):
|
||||
card = ttk.Frame(self.tab_status, style="Card.TFrame", padding=16)
|
||||
card.pack(fill="both", expand=True)
|
||||
|
||||
self.lbl_client_code = ttk.Label(card, text="Cliente ID: —", style="Card.TLabel", font=("Segoe UI", 10, "bold"))
|
||||
self.lbl_client_code.pack(anchor="w", pady=(0, 4))
|
||||
|
||||
self.lbl_client_name = ttk.Label(card, text="Nombre: —", style="Dim.TLabel")
|
||||
self.lbl_client_name.pack(anchor="w", pady=2)
|
||||
|
||||
self.lbl_server_url = ttk.Label(card, text="Servidor: —", style="Dim.TLabel")
|
||||
self.lbl_server_url.pack(anchor="w", pady=2)
|
||||
|
||||
self.lbl_device_id = ttk.Label(card, text="Device ID: —", style="Dim.TLabel")
|
||||
self.lbl_device_id.pack(anchor="w", pady=2)
|
||||
|
||||
ttk.Separator(card).pack(fill="x", pady=12)
|
||||
|
||||
ttk.Label(card, text="Motor de Transferencia:", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||||
ttk.Label(card, text="• Transferencia por bloques de 4 MB\n• Reanudación automática ante microcortes\n• Verificación estricta de integridad SHA-256\n• Detección de archivos en uso (Locks de SQL Server)", style="Dim.TLabel").pack(anchor="w", pady=6)
|
||||
|
||||
btn_refresh = ttk.Button(card, text="Actualizar Estado", style="Secondary.TButton", command=self._refresh_status)
|
||||
btn_refresh.pack(anchor="e", pady=(12, 0))
|
||||
|
||||
def _build_tab_jobs(self):
|
||||
self.jobs_container = ttk.Frame(self.tab_jobs, style="Card.TFrame", padding=12)
|
||||
self.jobs_container.pack(fill="both", expand=True)
|
||||
|
||||
self.jobs_list_lbl = ttk.Label(self.jobs_container, text="Cargando trabajos asignados por el servidor...", style="Dim.TLabel")
|
||||
self.jobs_list_lbl.pack(anchor="w", pady=10)
|
||||
|
||||
def _build_tab_manual(self):
|
||||
card = ttk.Frame(self.tab_manual, style="Card.TFrame", padding=16)
|
||||
card.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(card, text="Selecciona un archivo local para realizar un backup por chunks:", style="Card.TLabel").pack(anchor="w", pady=(0, 8))
|
||||
|
||||
file_frame = ttk.Frame(card, style="Card.TFrame")
|
||||
file_frame.pack(fill="x", pady=4)
|
||||
|
||||
self.entry_file = tk.Entry(file_frame, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||
self.entry_file.pack(side="left", fill="x", expand=True, ipady=6, padx=(0, 8))
|
||||
|
||||
btn_browse = ttk.Button(file_frame, text="Explorar...", style="Secondary.TButton", command=self._browse_file)
|
||||
btn_browse.pack(side="right")
|
||||
|
||||
self.btn_upload = ttk.Button(card, text="Iniciar Backup Inmediato", style="Primary.TButton", command=self._start_manual_backup)
|
||||
self.btn_upload.pack(fill="x", pady=16)
|
||||
|
||||
self.lbl_progress = ttk.Label(card, text="Estado: En espera", style="Dim.TLabel")
|
||||
self.lbl_progress.pack(anchor="w", pady=(0, 4))
|
||||
|
||||
self.progressbar = ttk.Progressbar(card, style="TProgressbar", mode="determinate")
|
||||
self.progressbar.pack(fill="x", pady=(0, 8))
|
||||
|
||||
def _build_tab_config(self):
|
||||
card = ttk.Frame(self.tab_config, style="Card.TFrame", padding=16)
|
||||
card.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(card, text="Servidor Central (URL):", style="Card.TLabel").pack(anchor="w")
|
||||
self.entry_server = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||
self.entry_server.insert(0, self.config.server_url)
|
||||
self.entry_server.pack(fill="x", ipady=6, pady=(4, 12))
|
||||
|
||||
ttk.Label(card, text="Código de Registro (generado en Web UI):", style="Card.TLabel").pack(anchor="w")
|
||||
self.entry_reg_code = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||
self.entry_reg_code.pack(fill="x", ipady=6, pady=(4, 16))
|
||||
|
||||
btn_register = ttk.Button(card, text="Registrar Dispositivo", style="Primary.TButton", command=self._register_device)
|
||||
btn_register.pack(fill="x")
|
||||
|
||||
def _browse_file(self):
|
||||
filename = filedialog.askopenfilename(title="Seleccionar archivo para backup", filetypes=[("Archivos SQL Server / Todos", "*.bak;*.mdf;*.*")])
|
||||
if filename:
|
||||
self.entry_file.delete(0, tk.END)
|
||||
self.entry_file.insert(0, filename)
|
||||
|
||||
def _start_manual_backup(self):
|
||||
filepath_str = self.entry_file.get().strip()
|
||||
if not filepath_str or not os.path.exists(filepath_str):
|
||||
messagebox.showerror("Error", "Por favor selecciona un archivo existente.")
|
||||
return
|
||||
|
||||
if not self.config.device_id:
|
||||
messagebox.showerror("Error", "El agente no está registrado contra el servidor.")
|
||||
return
|
||||
|
||||
self.btn_upload.configure(state="disabled")
|
||||
self.lbl_progress.configure(text="Iniciando subida por bloques...")
|
||||
self.progressbar["value"] = 0
|
||||
|
||||
def worker():
|
||||
try:
|
||||
filepath = Path(filepath_str)
|
||||
uploader = ChunkUploader(self.config)
|
||||
|
||||
def on_progress(done, total, pct):
|
||||
self.root.after(0, lambda: self._update_progress_ui(done, total, pct, filepath.name))
|
||||
|
||||
res = uploader.upload_file(filepath, progress_callback=on_progress)
|
||||
self.root.after(0, lambda: self._on_backup_success(filepath.name, res))
|
||||
except Exception as ex:
|
||||
self.root.after(0, lambda: self._on_backup_error(str(ex)))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _update_progress_ui(self, done, total, pct, filename):
|
||||
self.progressbar["value"] = pct
|
||||
self.lbl_progress.configure(text=f"Subiendo {filename}: {done}/{total} chunks ({pct:.1f}%)")
|
||||
|
||||
def _on_backup_success(self, filename, res):
|
||||
self.btn_upload.configure(state="normal")
|
||||
self.progressbar["value"] = 100
|
||||
self.lbl_progress.configure(text=f"✓ Backup completado y verificado: {filename}")
|
||||
messagebox.showinfo("Éxito", f"¡Backup completado con éxito!\n\nArchivo: {filename}\nSHA-256: {res.get('sha256')}\nRuta remota: {res.get('relative_path')}")
|
||||
|
||||
def _on_backup_error(self, err_msg):
|
||||
self.btn_upload.configure(state="normal")
|
||||
self.lbl_progress.configure(text=f"✗ Error: {err_msg}")
|
||||
messagebox.showerror("Error de Backup", f"Fallo al respaldar archivo:\n{err_msg}")
|
||||
|
||||
def _register_device(self):
|
||||
server_url = self.entry_server.get().strip().rstrip("/")
|
||||
code = self.entry_reg_code.get().strip().upper()
|
||||
|
||||
if not server_url or not code:
|
||||
messagebox.showerror("Error", "Debes ingresar la URL del servidor y el código de registro.")
|
||||
return
|
||||
|
||||
try:
|
||||
import socket, platform
|
||||
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:
|
||||
messagebox.showerror("Error de Registro", f"El servidor respondió: {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)
|
||||
|
||||
messagebox.showinfo("Registro Exitoso", f"¡Dispositivo registrado!\nCliente: {data['client_code']}\nID: {data['device_id']}")
|
||||
self._refresh_status()
|
||||
except Exception as ex:
|
||||
messagebox.showerror("Error de Conexión", f"No se pudo conectar al servidor:\n{str(ex)}")
|
||||
|
||||
def _refresh_status(self):
|
||||
self.config = load_config()
|
||||
if self.config.client_code:
|
||||
self.lbl_client_code.configure(text=f"Cliente ID: {self.config.client_code}")
|
||||
self.lbl_client_name.configure(text=f"Nombre: {self.config.client_name or '—'}")
|
||||
self.lbl_server_url.configure(text=f"Servidor: {self.config.server_url}")
|
||||
self.lbl_device_id.configure(text=f"Device ID: {self.config.device_id}")
|
||||
self.status_badge.configure(text="● Registrado", bg="#065F46", fg="#34D399")
|
||||
self._fetch_jobs()
|
||||
else:
|
||||
self.lbl_client_code.configure(text="Cliente ID: No Registrado")
|
||||
self.status_badge.configure(text="● Sin Registro", bg="#7F1D1D", fg="#FCA5A5")
|
||||
|
||||
def _fetch_jobs(self):
|
||||
def worker():
|
||||
try:
|
||||
headers = {"X-Device-Id": self.config.device_id, "X-Device-Token": self.config.device_token}
|
||||
with httpx.Client(base_url=self.config.server_url, headers=headers, timeout=10.0) as client:
|
||||
resp = client.get("/api/jobs/agent/assigned")
|
||||
if resp.status_code == 200:
|
||||
jobs = resp.json()
|
||||
self.root.after(0, lambda: self._render_jobs_list(jobs))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _render_jobs_list(self, jobs):
|
||||
for widget in self.jobs_container.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
if not jobs:
|
||||
ttk.Label(self.jobs_container, text="No hay trabajos programados asignados a este equipo.", style="Dim.TLabel").pack(pady=20)
|
||||
return
|
||||
|
||||
for j in jobs:
|
||||
item_frame = ttk.Frame(self.jobs_container, style="Card.TFrame", padding=8)
|
||||
item_frame.pack(fill="x", pady=4)
|
||||
|
||||
ttk.Label(item_frame, text=f"[{j['job_code']}] {j['name']}", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||||
ttk.Label(item_frame, text=f"Ruta: {j['source_path']} | Filtros: {j['file_patterns']} | Cron: {j['schedule_cron']}", style="Dim.TLabel").pack(anchor="w")
|
||||
|
||||
def launch_gui():
|
||||
root = tk.Tk()
|
||||
app = AgentGuiApp(root)
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch_gui()
|
||||
@@ -0,0 +1,140 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import pystray
|
||||
from pystray import MenuItem as item
|
||||
|
||||
# Add agent root to sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from agent.config import load_config, AgentConfig
|
||||
from agent.service import AgentDaemon
|
||||
from agent.uploader import ChunkUploader
|
||||
from create_icons import generate_app_icons
|
||||
|
||||
class WindowsTrayAgent:
|
||||
"""Windows System Tray (Área de Notificaciones) Application for OnEver Drive."""
|
||||
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
self.daemon = AgentDaemon(self.config)
|
||||
self.icon = None
|
||||
self.is_paused = False
|
||||
self.icon_image = self._load_icon()
|
||||
|
||||
def _load_icon(self) -> Image.Image:
|
||||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||
png_path = assets_dir / "icon.png"
|
||||
if not png_path.exists():
|
||||
_, png_path = generate_app_icons()
|
||||
return Image.open(png_path)
|
||||
|
||||
def _get_status_text(self) -> str:
|
||||
if not self.config.client_code:
|
||||
return "Estado: Sin Registrar"
|
||||
if self.is_paused:
|
||||
return "Estado: Pausado"
|
||||
return f"Estado: Conectado ({self.config.client_code})"
|
||||
|
||||
def _toggle_pause(self, icon, item_obj):
|
||||
self.is_paused = not self.is_paused
|
||||
if self.is_paused:
|
||||
self.daemon.stop()
|
||||
self.notify("Sincronización en pausa", "El servicio de backup ha sido pausado.")
|
||||
else:
|
||||
self.daemon.start()
|
||||
self.notify("Sincronización activa", "El servicio de backup se ha reanudado.")
|
||||
|
||||
def _open_gui(self, icon=None, item_obj=None):
|
||||
def run_gui():
|
||||
from agent_gui import launch_gui
|
||||
launch_gui()
|
||||
|
||||
threading.Thread(target=run_gui, daemon=True).start()
|
||||
|
||||
def _manual_backup(self, icon=None, item_obj=None):
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
|
||||
def run_picker():
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
filepath = filedialog.askopenfilename(
|
||||
title="Seleccionar archivo para backup",
|
||||
filetypes=[("Archivos SQL / Datos", "*.bak;*.mdf;*.*")]
|
||||
)
|
||||
if not filepath:
|
||||
root.destroy()
|
||||
return
|
||||
|
||||
self.notify("Iniciando Backup", f"Preparando transferencia por chunks: {Path(filepath).name}")
|
||||
|
||||
def upload_worker():
|
||||
try:
|
||||
uploader = ChunkUploader(self.config)
|
||||
res = uploader.upload_file(Path(filepath))
|
||||
self.notify("Backup Completado ✓", f"{Path(filepath).name} verificado con éxito en el servidor.")
|
||||
except Exception as ex:
|
||||
self.notify("Error en Backup ✗", f"Fallo al subir {Path(filepath).name}: {str(ex)}")
|
||||
|
||||
threading.Thread(target=upload_worker, daemon=True).start()
|
||||
root.destroy()
|
||||
|
||||
threading.Thread(target=run_picker, daemon=True).start()
|
||||
|
||||
def notify(self, title: str, message: str):
|
||||
"""Displays a native Windows Notification Balloon."""
|
||||
if self.icon:
|
||||
try:
|
||||
self.icon.notify(message, title)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_exit(self, icon, item_obj):
|
||||
self.daemon.stop()
|
||||
icon.stop()
|
||||
|
||||
def build_menu(self):
|
||||
client_title = f"OnEver Drive ({self.config.client_name or 'Agente'})"
|
||||
return pystray.Menu(
|
||||
item(client_title, lambda: None, enabled=False),
|
||||
item(lambda text: self._get_status_text(), lambda: None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
item("Abrir Panel de Control...", self._open_gui, default=True),
|
||||
item("Hacer Backup Manual...", self._manual_backup),
|
||||
item(lambda text: "Reanudar Servicio" if self.is_paused else "Pausar Servicio", self._toggle_pause),
|
||||
pystray.Menu.SEPARATOR,
|
||||
item("Salir", self._on_exit)
|
||||
)
|
||||
|
||||
def run(self):
|
||||
# Start background daemon worker
|
||||
if self.config.device_id:
|
||||
self.daemon.start()
|
||||
|
||||
# Create tray icon
|
||||
self.icon = pystray.Icon(
|
||||
name="OnEverDrive",
|
||||
icon=self.icon_image,
|
||||
title="OnEver Drive — Agente de Backup",
|
||||
menu=self.build_menu()
|
||||
)
|
||||
|
||||
# Notify on startup
|
||||
if self.config.client_code:
|
||||
self.notify("OnEver Drive Activo", f"Agente en ejecución ({self.config.client_code})")
|
||||
else:
|
||||
self.notify("OnEver Drive", "Agente iniciado. Requiere registro en el servidor.")
|
||||
|
||||
# Run system tray event loop
|
||||
self.icon.run()
|
||||
|
||||
def main():
|
||||
agent = WindowsTrayAgent()
|
||||
agent.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def build_windows_exe():
|
||||
agent_dir = Path(__file__).resolve().parent
|
||||
assets_dir = agent_dir / "assets"
|
||||
ico_path = assets_dir / "icon.ico"
|
||||
|
||||
if not ico_path.exists():
|
||||
print("[*] Generating icons...")
|
||||
from create_icons import generate_app_icons
|
||||
generate_app_icons()
|
||||
|
||||
print("========================================================================")
|
||||
print(" BUILDING ONEVER DRIVE MODERN PYQT6 WINDOWS EXECUTABLE (.EXE) ")
|
||||
print("========================================================================")
|
||||
|
||||
dist_dir = agent_dir / "dist"
|
||||
build_dir = agent_dir / "build"
|
||||
|
||||
# PyInstaller arguments for modern PyQt6 standalone agent
|
||||
pyinstaller_args = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--onedir",
|
||||
"--windowed", # No console popup, pure GUI and tray
|
||||
f"--icon={str(ico_path)}",
|
||||
f"--name=OnEverDriveAgent",
|
||||
f"--distpath={str(dist_dir)}",
|
||||
f"--workpath={str(build_dir)}",
|
||||
f"--add-data={str(assets_dir)}{os.pathsep}assets",
|
||||
"--hidden-import=PyQt6",
|
||||
"--hidden-import=PyQt6.QtCore",
|
||||
"--hidden-import=PyQt6.QtGui",
|
||||
"--hidden-import=PyQt6.QtWidgets",
|
||||
"--hidden-import=httpx",
|
||||
"--hidden-import=pydantic",
|
||||
"--hidden-import=pydantic_settings",
|
||||
"--hidden-import=schedule",
|
||||
"--hidden-import=sqlite3",
|
||||
str(agent_dir / "agent_app_pyqt.py")
|
||||
]
|
||||
|
||||
print(f"[*] Compiling PyQt6 directory bundle...")
|
||||
subprocess.run(pyinstaller_args, check=True, cwd=str(agent_dir))
|
||||
|
||||
# Single-file build
|
||||
standalone_args = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
f"--icon={str(ico_path)}",
|
||||
f"--name=OnEverDriveAgent-Standalone",
|
||||
f"--distpath={str(dist_dir)}",
|
||||
f"--workpath={str(build_dir)}",
|
||||
f"--add-data={str(assets_dir)}{os.pathsep}assets",
|
||||
"--hidden-import=PyQt6",
|
||||
"--hidden-import=PyQt6.QtCore",
|
||||
"--hidden-import=PyQt6.QtGui",
|
||||
"--hidden-import=PyQt6.QtWidgets",
|
||||
"--hidden-import=httpx",
|
||||
"--hidden-import=pydantic",
|
||||
"--hidden-import=pydantic_settings",
|
||||
"--hidden-import=schedule",
|
||||
"--hidden-import=sqlite3",
|
||||
str(agent_dir / "agent_app_pyqt.py")
|
||||
]
|
||||
|
||||
print(f"[*] Compiling standalone single-file .exe...")
|
||||
subprocess.run(standalone_args, check=True, cwd=str(agent_dir))
|
||||
|
||||
exe_path = dist_dir / "OnEverDriveAgent" / "OnEverDriveAgent.exe"
|
||||
single_exe_path = dist_dir / "OnEverDriveAgent-Standalone.exe"
|
||||
|
||||
print("")
|
||||
echo = "========================================================================"
|
||||
print(echo)
|
||||
print(f"[+] PyQt6 Executables built successfully:")
|
||||
print(f" 1. {exe_path}")
|
||||
print(f" 2. {single_exe_path}")
|
||||
print(echo)
|
||||
|
||||
return exe_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_windows_exe()
|
||||
@@ -0,0 +1,41 @@
|
||||
from PIL import Image, ImageDraw
|
||||
from pathlib import Path
|
||||
|
||||
def generate_app_icons():
|
||||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
size = (256, 256)
|
||||
img = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw rounded shield / background
|
||||
# Gradient/Cyan-Indigo background rounded rect
|
||||
draw.rounded_rectangle([(16, 16), (240, 240)], radius=48, fill="#0F172A", outline="#06B6D4", width=8)
|
||||
|
||||
# Draw cloud / shield icon
|
||||
# Cloud base
|
||||
draw.ellipse([(60, 110), (140, 170)], fill="#06B6D4")
|
||||
draw.ellipse([(110, 80), (190, 160)], fill="#38BDF8")
|
||||
draw.ellipse([(140, 110), (200, 170)], fill="#60A5FA")
|
||||
draw.rectangle([(100, 130), (170, 170)], fill="#06B6D4")
|
||||
|
||||
# Draw upward upload arrow inside cloud
|
||||
# Arrow head
|
||||
draw.polygon([(145, 110), (120, 135), (170, 135)], fill="#FFFFFF")
|
||||
# Arrow body
|
||||
draw.rectangle([(137, 135), (153, 160)], fill="#FFFFFF")
|
||||
|
||||
# Save PNG
|
||||
png_path = assets_dir / "icon.png"
|
||||
img.save(png_path, "PNG")
|
||||
|
||||
# Save ICO
|
||||
ico_path = assets_dir / "icon.ico"
|
||||
img.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
|
||||
|
||||
print(f"Icons generated at: {png_path} and {ico_path}")
|
||||
return ico_path, png_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_app_icons()
|
||||
@@ -0,0 +1,69 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
OnEver Drive — Windows Agent Service Installer
|
||||
.DESCRIPTION
|
||||
Installs and configures the OnEver Drive Windows Agent as a background system service.
|
||||
#>
|
||||
|
||||
param (
|
||||
[string]$ServerUrl = "http://127.0.0.1:8000",
|
||||
[string]$RegistrationCode = "",
|
||||
[string]$ClientName = ""
|
||||
)
|
||||
|
||||
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||
Write-Host " OnEver Drive — Windows Service Setup Script " -ForegroundColor Cyan
|
||||
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||
|
||||
$CurrentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AgentDir = Split-Path -Parent $CurrentDir
|
||||
$PythonExe = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
|
||||
|
||||
if (-not $PythonExe) {
|
||||
Write-Error "Python 3.10+ was not found on PATH. Please install Python first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[+] Detected Python executable: $PythonExe" -ForegroundColor Green
|
||||
Write-Host "[+] Agent root directory: $AgentDir" -ForegroundColor Green
|
||||
|
||||
# 1. If registration code provided, perform initial registration
|
||||
if ($RegistrationCode -ne "") {
|
||||
Write-Host "[*] Registering agent against server: $ServerUrl..." -ForegroundColor Yellow
|
||||
$RegArgs = @("$AgentDir\agent_cli.py", "register", "--server", $ServerUrl, "--code", $RegistrationCode)
|
||||
if ($ClientName -ne "") {
|
||||
$RegArgs += @("--name", $ClientName)
|
||||
}
|
||||
& $PythonExe $RegArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Registration failed. Please check registration code and server connectivity."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Service definition
|
||||
$ServiceName = "OnEverDriveAgent"
|
||||
$ServiceDisplayName = "OnEver Drive Backup Agent Service"
|
||||
$ServiceDescription = "Enterprise chunked backup and sync daemon for OnEver Drive Proxmox platform."
|
||||
$ServiceBinary = "`"$PythonExe`" `"$AgentDir\agent_cli.py`" daemon"
|
||||
|
||||
Write-Host "[*] Registering Windows Service: $ServiceName..." -ForegroundColor Yellow
|
||||
|
||||
# Stop and remove existing service if present
|
||||
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($ExistingService) {
|
||||
Write-Host "[-] Stopping and removing existing service..." -ForegroundColor Yellow
|
||||
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
||||
sc.exe delete $ServiceName
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Create service using sc.exe
|
||||
sc.exe create $ServiceName binPath= $ServiceBinary start= auto DisplayName= $ServiceDisplayName
|
||||
sc.exe description $ServiceName $ServiceDescription
|
||||
|
||||
# Configure recovery options (restart service on crash)
|
||||
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000
|
||||
|
||||
Write-Host "[+] Service '$ServiceName' registered successfully!" -ForegroundColor Green
|
||||
Write-Host "[*] To start the service run: Start-Service $ServiceName" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,5 @@
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.6.0
|
||||
pydantic-settings>=2.2.0
|
||||
schedule>=1.2.1
|
||||
colorama>=0.4.6
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
title OnEver Drive - Windows Agent Launcher (PyQt6)
|
||||
cd /d "%~dp0"
|
||||
echo Starting OnEver Drive PyQt6 Agent in notification area...
|
||||
start "" "%~dp0dist\OnEverDriveAgent\OnEverDriveAgent.exe"
|
||||
exit
|
||||
Reference in New Issue
Block a user