diff --git a/README.md b/README.md index 5090770..0d63188 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Plataforma empresarial centralizada de backup y sincronización para entornos Wi ┌────────────────────────────────────────────────────────┐ │ CLIENTES WINDOWS (10/11/Server) │ │ │ - │ [ Agente PyQt6 / System Tray (.EXE) ] │ + │ [ Agente PyQt6 / System Tray (.EXE) ] │ │ ├── Selector visual de carpetas (QFileDialog) │ │ ├── Detección de bloqueos SQL Server (Locks/Growth) │ │ ├── Motor de Chunks (Bloques de 4MB con reanudación) │ @@ -44,6 +44,39 @@ Plataforma empresarial centralizada de backup y sincronización para entornos Wi --- +## 🧪 Ejecución Local para Desarrollo y Pruebas + +Para depurar y probar la aplicación en tu máquina local: + +### Backend (FastAPI) + +```bash +cd backend +# 1. Crear y activar entorno virtual +python -m venv venv +# Windows: venv\Scripts\activate +# Linux/Mac: source venv/bin/activate + +# 2. Instalar dependencias +pip install -r requirements.txt + +# 3. Ejecutar servidor en modo desarrollo +uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +``` + +### Frontend (React + Vite) + +```bash +cd frontend +# 1. Instalar dependencias +npm install + +# 2. Iniciar servidor de desarrollo +npm run dev +``` + +--- + ## 🛠️ Requisitos de Despliegue en Proxmox VE El backend y frontend se despliegan en un **Contenedor LXC Debian 12/13** sin dependencias de Docker: diff --git a/backend/app/api/clients.py b/backend/app/api/clients.py index 1558979..c343eed 100644 --- a/backend/app/api/clients.py +++ b/backend/app/api/clients.py @@ -10,7 +10,8 @@ from app.core.security import generate_registration_code, generate_device_token, from app.models.models import User, Client, ClientCredential, RegistrationCode from app.schemas.schemas import ( ClientResponse, ClientRegisterRequest, ClientRegisterResponse, - RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest + RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest, + ClientUpdateRequest ) from app.api.deps import get_current_user, require_admin, get_current_client from app.services.event_service import log_event @@ -78,27 +79,55 @@ async def register_client( detail="Invalid, expired, or already used registration code." ) - # Determine next client code (e.g., CLIENT-0001) - count_res = await db.execute(select(func.count(Client.id))) - client_count = count_res.scalar() or 0 - client_code = f"CLIENT-{client_count + 1:04d}" - client_ip = request.client.host if request.client else None - # Create Client - client = Client( - client_code=client_code, - name=payload.name or reg_code.client_name_hint or payload.hostname, - hostname=payload.hostname, - os_info=payload.os_info, - ip_address=client_ip, - agent_version=payload.agent_version, - status="ONLINE", - last_seen_at=now, - is_active=True - ) - db.add(client) - await db.flush() + if reg_code.client_id: + # Re-registering an existing client! + client_res = await db.execute(select(Client).where(Client.id == reg_code.client_id)) + client = client_res.scalar_one_or_none() + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Client associated with this registration code not found." + ) + # Update connection info and mark as active + client.hostname = payload.hostname + client.os_info = payload.os_info + client.ip_address = client_ip + client.agent_version = payload.agent_version + client.status = "ONLINE" + client.last_seen_at = now + client.is_active = True + else: + # Create a new Client + max_id_res = await db.execute(select(func.max(Client.id))) + max_id = max_id_res.scalar() or 0 + client_code = f"CLIENT-{max_id + 1:04d}" + + # Load default client quota from settings + from app.services.settings_service import get_setting + default_quota_gb_str = await get_setting(db, "default_client_quota_gb") + default_quota_bytes = 100 * 1024 * 1024 * 1024 + if default_quota_gb_str: + try: + default_quota_bytes = int(default_quota_gb_str) * 1024 * 1024 * 1024 + except ValueError: + pass + + client = Client( + client_code=client_code, + name=payload.name or reg_code.client_name_hint or payload.hostname, + hostname=payload.hostname, + os_info=payload.os_info, + ip_address=client_ip, + agent_version=payload.agent_version, + status="ONLINE", + last_seen_at=now, + is_active=True, + storage_quota_bytes=default_quota_bytes + ) + db.add(client) + await db.flush() # Generate device unique ID and secret token device_id = str(uuid.uuid4()) @@ -220,6 +249,90 @@ async def revoke_client_credentials( return {"message": "Client credentials successfully revoked"} +@router.post("/{client_id}/re-register", response_model=RegistrationCodeResponse) +async def generate_client_re_registration_code( + client_id: int, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + """Generates a registration code to re-link an existing client's agent.""" + client_res = await db.execute(select(Client).where(Client.id == client_id)) + client = client_res.scalar_one_or_none() + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Client not found" + ) + + # Revoke old credentials to prepare for the new agent connection + cred_res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id)) + creds = cred_res.scalars().all() + for cred in creds: + cred.is_revoked = True + + code_str = generate_registration_code() + expires = datetime.now(timezone.utc) + timedelta(hours=24) # 24 hours to re-register + + reg_code = RegistrationCode( + code=code_str, + client_id=client.id, + client_name_hint=client.name, + expires_at=expires, + is_used=False + ) + db.add(reg_code) + await db.commit() + await db.refresh(reg_code) + + await log_event( + db=db, + event_type="REGISTRATION_CODE_GENERATED", + message=f"Generated re-registration code {code_str} for client {client.name} ({client.client_code}).", + severity="INFO", + client_id=client_id, + user_email=admin_user.email + ) + + return reg_code + +@router.patch("/{client_id}", response_model=ClientResponse) +async def update_client( + client_id: int, + payload: ClientUpdateRequest, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + """Updates client details such as alias.""" + res = await db.execute(select(Client).where(Client.id == client_id)) + client = res.scalar_one_or_none() + if not client: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found") + + if payload.alias is not None: + client.alias = payload.alias + if payload.storage_quota_bytes is not None: + client.storage_quota_bytes = payload.storage_quota_bytes + + await db.commit() + await db.refresh(client) + + message_parts = [] + if payload.alias is not None: + message_parts.append(f"alias to '{client.alias}'") + if payload.storage_quota_bytes is not None: + message_parts.append(f"quota to {client.storage_quota_bytes} bytes") + + await log_event( + db=db, + event_type="CLIENT_UPDATED", + message=f"Updated client {client.client_code}: {', '.join(message_parts)}.", + severity="INFO", + client_id=client.id, + user_email=admin_user.email + ) + + return client + @router.delete("/{client_id}") async def delete_client( client_id: int, diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py index 2dc648c..e26a7ba 100644 --- a/backend/app/api/jobs.py +++ b/backend/app/api/jobs.py @@ -6,7 +6,7 @@ from sqlalchemy import select, func, desc from app.core.database import get_db from app.models.models import User, Client, BackupJob -from app.schemas.schemas import JobCreate, JobUpdate, JobResponse +from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister from app.api.deps import get_current_user, require_admin, get_current_client from app.services.event_service import log_event from app.ws.manager import ws_manager @@ -36,6 +36,74 @@ async def get_agent_jobs( result = await db.execute(query) return result.scalars().all() +@router.post("/agent/register", response_model=JobResponse) +async def agent_register_job( + payload: AgentJobRegister, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """Called by the Windows Agent to register a new local job/folder on the server.""" + count_res = await db.execute(select(func.count(BackupJob.id))) + job_count = count_res.scalar() or 0 + job_code = f"JOB-{job_count + 1:03d}" + + job = BackupJob( + job_code=job_code, + client_id=current_client.id, + name=payload.name, + source_path=payload.source_path, + file_patterns=payload.file_patterns, + schedule_cron=payload.schedule_cron, + keep_daily=payload.keep_daily, + keep_weekly=payload.keep_weekly, + keep_monthly=payload.keep_monthly, + min_stable_time_seconds=payload.min_stable_time_seconds, + status="IDLE", + is_active=True + ) + db.add(job) + await db.commit() + await db.refresh(job) + + await log_event( + db=db, + event_type="JOB_CREATED", + message=f"Agent registered backup job '{job.name}' ({job.job_code}) from device.", + severity="INFO", + client_id=current_client.id, + job_id=job.id + ) + + return job + +@router.delete("/agent/{job_id}") +async def agent_delete_job( + job_id: int, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """Called by the Windows Agent to delete a backup job it owns.""" + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found or not owned by this client" + ) + + await db.delete(job) + await db.commit() + + await log_event( + db=db, + event_type="JOB_DELETED", + message=f"Agent deleted backup job {job.job_code} from device.", + severity="WARNING", + client_id=current_client.id + ) + + return {"message": "Job successfully deleted by agent"} + @router.post("", response_model=JobResponse) async def create_job( payload: JobCreate, diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..afab321 --- /dev/null +++ b/backend/app/api/settings.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.api.deps import require_admin +from app.models.models import User +from app.schemas.schemas import SystemSettingsResponse, SystemSettingsUpdate +from app.services.settings_service import get_all_settings, update_settings_service + +router = APIRouter(prefix="/settings", tags=["System Settings"]) + +@router.get("", response_model=SystemSettingsResponse) +async def get_settings( + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + """Retrieve global system settings.""" + settings_dict = await get_all_settings(db) + return { + "storage_root": settings_dict.get("storage_root", ""), + "global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)), + "default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)), + "default_keep_daily": int(settings_dict.get("default_keep_daily", 7)), + "default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)), + "default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)), + } + +@router.put("", response_model=SystemSettingsResponse) +async def update_settings( + payload: SystemSettingsUpdate, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + """Update global system settings.""" + # Convert Pydantic model to a dict of values (filtering out None) + updates = {k: v for k, v in payload.model_dump().items() if v is not None} + + try: + settings_dict = await update_settings_service(db, updates) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + return { + "storage_root": settings_dict.get("storage_root", ""), + "global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)), + "default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)), + "default_keep_daily": int(settings_dict.get("default_keep_daily", 7)), + "default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)), + "default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)), + } diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 0d515aa..b48270a 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -67,6 +67,23 @@ async def get_dashboard_stats( # Storage metrics storage_stats = await storage_provider.get_storage_stats() + + # Override with global virtual quota if set + from app.services.settings_service import get_setting + global_quota_gb_str = await get_setting(db, "global_quota_gb") + if global_quota_gb_str: + try: + global_quota_bytes = int(global_quota_gb_str) * 1024 * 1024 * 1024 + # Sum up storage used by all clients + used_res = await db.execute(select(func.sum(Client.storage_used_bytes))) + total_used_bytes = used_res.scalar() or 0 + + storage_stats["total_bytes"] = global_quota_bytes + storage_stats["used_bytes"] = total_used_bytes + storage_stats["free_bytes"] = max(0, global_quota_bytes - total_used_bytes) + storage_stats["usage_percent"] = round((total_used_bytes / global_quota_bytes) * 100, 2) if global_quota_bytes > 0 else 0 + except ValueError: + pass return { "total_clients": total_clients, diff --git a/backend/app/main.py b/backend/app/main.py index 14553dd..563fdd5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.api.upload import router as upload_router from app.api.backups import router as backups_router from app.api.events import router as events_router from app.api.stats import router as stats_router +from app.api.settings import router as settings_router from app.ws.manager import ws_manager @asynccontextmanager @@ -21,8 +22,22 @@ async def lifespan(app: FastAPI): # Initialize database tables await init_db() - # Seed default administrator if not present + # Seed default administrator and initialize settings async with AsyncSessionLocal() as session: + # Load and apply settings to storage_provider + from app.services.settings_service import get_all_settings + settings_dict = await get_all_settings(session) + + # Apply storage_root + from app.storage.local import storage_provider + from pathlib import Path + import os + storage_root = settings_dict.get("storage_root") + if storage_root: + path = Path(storage_root).resolve() + os.makedirs(path, exist_ok=True) + storage_provider.root_dir = path + result = await session.execute(select(User)) admin = result.scalar_one_or_none() if not admin: @@ -63,6 +78,7 @@ app.include_router(upload_router, prefix=settings.API_V1_PREFIX) app.include_router(backups_router, prefix=settings.API_V1_PREFIX) app.include_router(events_router, prefix=settings.API_V1_PREFIX) app.include_router(stats_router, prefix=settings.API_V1_PREFIX) +app.include_router(settings_router, prefix=settings.API_V1_PREFIX) @app.websocket("/ws/telemetry") async def websocket_telemetry(websocket: WebSocket): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e6a23f1..4fc455b 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -27,6 +27,7 @@ class Client(Base): id = Column(Integer, primary_key=True, index=True) client_code = Column(String(50), unique=True, index=True, nullable=False) # e.g., CLIENT-0001 name = Column(String(255), nullable=False) + alias = Column(String(255), nullable=True) hostname = Column(String(255), nullable=True) os_info = Column(String(255), nullable=True) ip_address = Column(String(100), nullable=True) @@ -65,6 +66,7 @@ class RegistrationCode(Base): code = Column(String(50), unique=True, index=True, nullable=False) # e.g., OED-A1B2-C3D4 is_used = Column(Boolean, default=False, nullable=False) client_name_hint = Column(String(255), nullable=True) + client_id = Column(Integer, ForeignKey("clients.id"), nullable=True) expires_at = Column(DateTime(timezone=True), nullable=False) created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) @@ -172,3 +174,9 @@ class EventLog(Base): ip_address = Column(String(100), nullable=True) message = Column(Text, nullable=False) details_json = Column(Text, nullable=True) + +class SystemSetting(Base): + __tablename__ = "system_settings" + + key = Column(String(100), primary_key=True, index=True) + value = Column(String(1024), nullable=False) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 60563ca..697215b 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -28,9 +28,11 @@ class RegistrationCodeCreate(BaseModel): expires_in_hours: int = 48 class RegistrationCodeResponse(BaseModel): + id: int code: str expires_at: datetime client_name_hint: Optional[str] + client_id: Optional[int] = None class ClientRegisterRequest(BaseModel): registration_code: str @@ -51,10 +53,15 @@ class ClientHeartbeatRequest(BaseModel): agent_version: Optional[str] = None ip_address: Optional[str] = None +class ClientUpdateRequest(BaseModel): + alias: Optional[str] = None + storage_quota_bytes: Optional[int] = None + class ClientResponse(BaseModel): id: int client_code: str name: str + alias: Optional[str] hostname: Optional[str] os_info: Optional[str] ip_address: Optional[str] @@ -205,3 +212,30 @@ class DashboardStatsResponse(BaseModel): backups_today_failed: int active_uploads_count: int storage: StorageStatsResponse + +# --- System Settings Schemas --- +class SystemSettingsResponse(BaseModel): + storage_root: str + global_quota_gb: int + default_client_quota_gb: int + default_keep_daily: int + default_keep_weekly: int + default_keep_monthly: int + +class SystemSettingsUpdate(BaseModel): + storage_root: Optional[str] = None + global_quota_gb: Optional[int] = None + default_client_quota_gb: Optional[int] = None + default_keep_daily: Optional[int] = None + default_keep_weekly: Optional[int] = None + default_keep_monthly: Optional[int] = None + +class AgentJobRegister(BaseModel): + name: str + source_path: str + file_patterns: str = "*.bak,*.mdf" + schedule_cron: str = "daily" + keep_daily: int = 7 + keep_weekly: int = 4 + keep_monthly: int = 12 + min_stable_time_seconds: int = 60 diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py new file mode 100644 index 0000000..ea4378a --- /dev/null +++ b/backend/app/services/settings_service.py @@ -0,0 +1,67 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.models import SystemSetting +from app.core.config import settings +from pathlib import Path +import os + +DEFAULT_SETTINGS = { + "storage_root": settings.STORAGE_ROOT, + "global_quota_gb": "1000", + "default_client_quota_gb": "100", + "default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY), + "default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY), + "default_keep_monthly": str(settings.DEFAULT_RETENTION_MONTHLY), +} + +async def get_setting(db: AsyncSession, key: str) -> str: + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + return setting.value + return DEFAULT_SETTINGS.get(key, "") + +async def get_all_settings(db: AsyncSession) -> dict: + result = await db.execute(select(SystemSetting)) + db_settings = {s.key: s.value for s in result.scalars().all()} + + # Merge defaults and add them if not present in db + updated = False + for k, v in DEFAULT_SETTINGS.items(): + if k not in db_settings: + db_settings[k] = v + db.add(SystemSetting(key=k, value=v)) + updated = True + if updated: + await db.commit() + return db_settings + +async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict: + from app.storage.local import storage_provider + + for k, v in new_settings.items(): + if k in DEFAULT_SETTINGS and v is not None: + # If changing storage root, validate and apply + if k == "storage_root": + path = Path(v).resolve() + try: + os.makedirs(path, exist_ok=True) + # Test write access + test_file = path / ".write_test" + test_file.touch() + test_file.unlink() + except Exception as e: + raise ValueError(f"Invalid or unwritable storage root path: {str(e)}") + + # Apply to in-memory storage provider + storage_provider.root_dir = path + + result = await db.execute(select(SystemSetting).where(SystemSetting.key == k)) + setting = result.scalar_one_or_none() + if setting: + setting.value = str(v) + else: + db.add(SystemSetting(key=k, value=str(v))) + + await db.commit() + return await get_all_settings(db) diff --git a/backend/app/services/upload_service.py b/backend/app/services/upload_service.py index 501f745..bc703fb 100644 --- a/backend/app/services/upload_service.py +++ b/backend/app/services/upload_service.py @@ -24,6 +24,28 @@ async def create_or_resume_session( """ total_chunks = max(1, math.ceil(file_size / chunk_size)) + # 1. Check client quota + if client.storage_used_bytes + file_size > client.storage_quota_bytes: + raise ValueError( + f"Client storage quota exceeded. Limit: {client.storage_quota_bytes} bytes. Attempted to upload: {file_size} bytes." + ) + + # 2. Check global quota + from app.services.settings_service import get_setting + from sqlalchemy import func + global_quota_gb_str = await get_setting(db, "global_quota_gb") + if global_quota_gb_str: + try: + global_quota_bytes = int(global_quota_gb_str) * 1024 * 1024 * 1024 + used_res = await db.execute(select(func.sum(Client.storage_used_bytes))) + total_used_bytes = used_res.scalar() or 0 + if total_used_bytes + file_size > global_quota_bytes: + raise ValueError( + f"Global storage quota exceeded. Limit: {global_quota_bytes} bytes. Current used: {total_used_bytes} bytes." + ) + except ValueError: + pass + # Check for existing incomplete session for this client and file hash query = ( select(BackupSession) @@ -193,6 +215,8 @@ async def complete_session( job = job_res.scalar_one_or_none() if job: job_code = job.job_code + job.status = "RUNNING" + job.last_run_at = datetime.now(timezone.utc) session.status = "ASSEMBLING" await db.commit() @@ -230,6 +254,12 @@ async def complete_session( client.storage_used_bytes += total_bytes client.last_backup_at = datetime.now(timezone.utc) + if session.job_id: + job_res = await db.execute(select(BackupJob).where(BackupJob.id == session.job_id)) + job = job_res.scalar_one_or_none() + if job: + job.status = "SUCCESS" + await db.commit() await db.refresh(backup_file) @@ -263,6 +293,16 @@ async def complete_session( except Exception as ex: session.status = "FAILED" session.error_message = str(ex) + + if session.job_id: + try: + job_res = await db.execute(select(BackupJob).where(BackupJob.id == session.job_id)) + job = job_res.scalar_one_or_none() + if job: + job.status = "FAILED" + except Exception: + pass + await db.commit() await log_event( diff --git a/backend/migrate_alias.py b/backend/migrate_alias.py new file mode 100644 index 0000000..042e0bb --- /dev/null +++ b/backend/migrate_alias.py @@ -0,0 +1,20 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db") + +def migrate(): + print(f"Connecting to {DB_PATH}...") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + try: + cursor.execute("ALTER TABLE clients ADD COLUMN alias VARCHAR(255)") + print("Column 'alias' added successfully.") + except sqlite3.OperationalError as e: + print(f"Error (maybe column already exists?): {e}") + finally: + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/backend/migrate_re_register.py b/backend/migrate_re_register.py new file mode 100644 index 0000000..2d62b6e --- /dev/null +++ b/backend/migrate_re_register.py @@ -0,0 +1,20 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db") + +def migrate(): + print(f"Connecting to {DB_PATH}...") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + try: + cursor.execute("ALTER TABLE registration_codes ADD COLUMN client_id INTEGER REFERENCES clients(id)") + print("Column 'client_id' added successfully to registration_codes.") + except sqlite3.OperationalError as e: + print(f"Error (maybe column already exists?): {e}") + finally: + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 98c3f08..7c03670 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1227,6 +1228,7 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1237,6 +1239,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1305,6 +1308,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -1575,6 +1579,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1616,6 +1621,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1789,6 +1795,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c84a9f5..a20d37f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { JobsView } from './pages/JobsView'; import { RestoreView } from './pages/RestoreView'; import { EventsView } from './pages/EventsView'; import { LoginView } from './pages/LoginView'; +import { SettingsView } from './pages/SettingsView'; import { ActiveUpload } from './components/LiveTransferMeter'; import { api, @@ -16,7 +17,8 @@ import { EventLogItem, getAuthToken, getCurrentUser, - setAuthToken + setAuthToken, + SystemSettingsResponse } from './services/api'; import { wsClient } from './services/websocket'; @@ -32,6 +34,7 @@ export const App: React.FC = () => { const [clients, setClients] = useState([]); const [jobs, setJobs] = useState([]); const [events, setEvents] = useState([]); + const [settings, setSettings] = useState(null); const [activeUploads, setActiveUploads] = useState([]); const handleLoginSuccess = (user: any) => { @@ -54,17 +57,19 @@ export const App: React.FC = () => { if (!getAuthToken()) return; setIsLoading(true); try { - const [statsRes, clientsRes, jobsRes, eventsRes] = await Promise.all([ + const [statsRes, clientsRes, jobsRes, eventsRes, settingsRes] = await Promise.all([ api.getStats().catch(() => null), api.getClients().catch(() => []), api.getJobs().catch(() => []), api.getEvents(50).catch(() => []), + api.getSettings().catch(() => null), ]); if (statsRes) setStats(statsRes); setClients(clientsRes); setJobs(jobsRes); setEvents(eventsRes); + if (settingsRes) setSettings(settingsRes); } catch (err) { console.error('Error loading dashboard data:', err); } finally { @@ -147,6 +152,8 @@ export const App: React.FC = () => { return 'Explorador & Restore'; case 'events': return 'Auditoría & Logs'; + case 'settings': + return 'Configuración Global'; default: return 'OnEver Drive'; } @@ -191,6 +198,7 @@ export const App: React.FC = () => { jobs={jobs} clients={clients} onRefresh={loadData} + settings={settings} /> )} @@ -205,6 +213,12 @@ export const App: React.FC = () => { events={events} /> )} + + {currentTab === 'settings' && ( + + )} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 10f4da9..076246c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,7 +6,8 @@ import { RotateCcw, FileText, ShieldCheck, - Server + Server, + Settings } from 'lucide-react'; interface SidebarProps { @@ -22,6 +23,7 @@ export const Sidebar: React.FC = ({ currentTab, setCurrentTab, isW { id: 'jobs', label: 'Trabajos de Backup', icon: Layers }, { id: 'restore', label: 'Explorador & Restore', icon: RotateCcw }, { id: 'events', label: 'Auditoría & Logs', icon: FileText }, + { id: 'settings', label: 'Configuración Global', icon: Settings }, ]; return ( diff --git a/frontend/src/pages/ClientsView.tsx b/frontend/src/pages/ClientsView.tsx index a810707..e94d83c 100644 --- a/frontend/src/pages/ClientsView.tsx +++ b/frontend/src/pages/ClientsView.tsx @@ -8,7 +8,9 @@ import { Trash2, Laptop, Server as ServerIcon, - X + X, + Edit2, + RefreshCw } from 'lucide-react'; import { ClientItem, api } from '../services/api'; @@ -19,6 +21,10 @@ interface ClientsViewProps { export const ClientsView: React.FC = ({ clients, onRefresh }) => { const [showModal, setShowModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [editAlias, setEditAlias] = useState(''); + const [editQuotaGb, setEditQuotaGb] = useState(100); const [clientHint, setClientHint] = useState(''); const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null); const [isCopied, setIsCopied] = useState(false); @@ -67,6 +73,47 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) } }; + const handleReRegister = async (client: ClientItem) => { + if (confirm(`¿Estás seguro de que deseas volver a generar el código de vinculación para el cliente "${client.name}"?\n\nEsto revocará de inmediato los tokens de acceso actuales del equipo.`)) { + setLoading(true); + try { + const codeData = await api.generateReRegisterCode(client.id); + setGeneratedCode(codeData); + setShowModal(true); + } catch (err: any) { + alert(`Error al generar código de re-vinculación: ${err.message}`); + } finally { + setLoading(false); + } + } + }; + + const handleOpenEditModal = (client: ClientItem) => { + setEditingClient(client); + setEditAlias(client.alias || ''); + setEditQuotaGb(Math.round(client.storage_quota_bytes / (1024 * 1024 * 1024))); + setShowEditModal(true); + }; + + const handleSaveEdit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingClient) return; + setLoading(true); + try { + const quotaBytes = editQuotaGb * 1024 * 1024 * 1024; + await api.updateClient(editingClient.id, { + alias: editAlias.trim(), + storage_quota_bytes: quotaBytes + }); + setShowEditModal(false); + onRefresh(); + } catch (err: any) { + alert(`Error al actualizar cliente: ${err.message}`); + } finally { + setLoading(false); + } + }; + const serverUrl = window.location.origin; const psCommand = generatedCode ? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"` @@ -100,6 +147,7 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) Cliente ID Nombre / Hostname + Ubicación / Alias Sistema Operativo Dirección IP Estado @@ -133,6 +181,9 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) + + {client.alias || '—'} + {client.os_info || 'Windows'} @@ -145,7 +196,12 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) - {formatBytes(client.storage_used_bytes)} +
+
{formatBytes(client.storage_used_bytes)}
+
+ de {formatBytes(client.storage_quota_bytes)} +
+
{client.last_seen_at @@ -154,6 +210,14 @@ export const ClientsView: React.FC = ({ clients, onRefresh })
+ +
)} + {/* Edit Client Modal */} + {showEditModal && editingClient && ( +
+
+
+

Editar Cliente — {editingClient.client_code}

+ +
+ +
+
+ + +
+ +
+ + setEditAlias(e.target.value)} + /> +
+ +
+ + setEditQuotaGb(Number(e.target.value))} + /> +
+ +
+ + +
+
+
+
+ )} ); }; diff --git a/frontend/src/pages/JobsView.tsx b/frontend/src/pages/JobsView.tsx index 26a0852..f118c8c 100644 --- a/frontend/src/pages/JobsView.tsx +++ b/frontend/src/pages/JobsView.tsx @@ -9,15 +9,16 @@ import { Calendar, X } from 'lucide-react'; -import { BackupJobItem, ClientItem, api } from '../services/api'; +import { BackupJobItem, ClientItem, api, SystemSettingsResponse } from '../services/api'; interface JobsViewProps { jobs: BackupJobItem[]; clients: ClientItem[]; onRefresh: () => void; + settings?: SystemSettingsResponse | null; } -export const JobsView: React.FC = ({ jobs, clients, onRefresh }) => { +export const JobsView: React.FC = ({ jobs, clients, onRefresh, settings }) => { const [showModal, setShowModal] = useState(false); const [loading, setLoading] = useState(false); const [formData, setFormData] = useState({ @@ -92,7 +93,17 @@ export const JobsView: React.FC = ({ jobs, clients, onRefresh }) alert('Primero debes registrar al menos un cliente Windows.'); return; } - setFormData((prev) => ({ ...prev, client_id: clients[0].id })); + setFormData({ + client_id: clients[0].id, + name: '', + source_path: 'C:\\SQLBackups', + file_patterns: '*.bak,*.mdf', + schedule_cron: '0 2 * * *', + keep_daily: settings?.default_keep_daily ?? 7, + keep_weekly: settings?.default_keep_weekly ?? 4, + keep_monthly: settings?.default_keep_monthly ?? 12, + min_stable_time_seconds: 60, + }); setShowModal(true); }} > diff --git a/frontend/src/pages/RestoreView.tsx b/frontend/src/pages/RestoreView.tsx index 1ca8bd4..3a7f10c 100644 --- a/frontend/src/pages/RestoreView.tsx +++ b/frontend/src/pages/RestoreView.tsx @@ -17,9 +17,20 @@ interface RestoreViewProps { export const RestoreView: React.FC = ({ clients }) => { const [backups, setBackups] = useState([]); const [selectedClientId, setSelectedClientId] = useState(undefined); + const [selectedGroup, setSelectedGroup] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); + // Get unique sorted group names (aliases) from clients + const groups = Array.from( + new Set(clients.map((c) => c.alias).filter((alias): alias is string => !!alias)) + ).sort(); + + // Filter clients shown in dropdown based on selected group + const filteredClientsForDropdown = selectedGroup + ? clients.filter((c) => c.alias === selectedGroup) + : clients; + const fetchBackups = async () => { setLoading(true); try { @@ -44,8 +55,12 @@ export const RestoreView: React.FC = ({ clients }) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; - const handleDownload = (backup: BackupFileItem) => { - window.open(`/api/backups/${backup.id}/download`, '_blank'); + const handleDownload = async (backup: BackupFileItem) => { + try { + await api.downloadBackup(backup); + } catch (err: any) { + alert(`Error descargando archivo: ${err.message}`); + } }; const handleDelete = async (backup: BackupFileItem) => { @@ -59,14 +74,40 @@ export const RestoreView: React.FC = ({ clients }) => { } }; - const filteredBackups = backups.filter((b) => - b.filename.toLowerCase().includes(searchQuery.toLowerCase()) || - b.sha256.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredBackups = backups.filter((b) => { + const query = searchQuery.toLowerCase(); + + // Check filename and SHA-256 + const matchesFile = b.filename.toLowerCase().includes(query) || + b.sha256.toLowerCase().includes(query); + + // Find client for this backup and check Name, Hostname, Client ID and Alias + const client = clients.find((c) => c.id === b.client_id); + + // Filter by selected group (alias) if specified + if (selectedGroup && (!client || client.alias !== selectedGroup)) { + return false; + } + + // Filter by selected client if specified + if (selectedClientId && b.client_id !== selectedClientId) { + return false; + } + + const matchesClient = client + ? client.name.toLowerCase().includes(query) || + (client.hostname && client.hostname.toLowerCase().includes(query)) || + client.client_code.toLowerCase().includes(query) || + (client.alias && client.alias.toLowerCase().includes(query)) + : false; + + return matchesFile || matchesClient; + }); const getClientName = (clientId: number) => { const c = clients.find((client) => client.id === clientId); - return c ? `${c.name} (${c.client_code})` : `Cliente #${clientId}`; + if (!c) return `Cliente #${clientId}`; + return `${c.hostname || c.name} [${c.alias || c.name}] (${c.client_code})`; }; return ( @@ -88,13 +129,33 @@ export const RestoreView: React.FC = ({ clients }) => { type="text" className="form-input" style={{ paddingLeft: '36px' }} - placeholder="Buscar por nombre de archivo o hash SHA-256..." + placeholder="Buscar por archivo, hash, alias, equipo o cliente ID..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> +
+ + +
+
diff --git a/frontend/src/pages/SettingsView.tsx b/frontend/src/pages/SettingsView.tsx new file mode 100644 index 0000000..82692f3 --- /dev/null +++ b/frontend/src/pages/SettingsView.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect } from 'react'; +import { + Save, + Folder, + HardDrive, + Calendar, + Loader2, + ShieldCheck, + AlertCircle +} from 'lucide-react'; +import { api, SystemSettingsResponse } from '../services/api'; + +interface SettingsViewProps { + onRefresh: () => void; +} + +export const SettingsView: React.FC = ({ onRefresh }) => { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const [formData, setFormData] = useState({ + storage_root: '', + global_quota_gb: 1000, + default_client_quota_gb: 100, + default_keep_daily: 7, + default_keep_weekly: 4, + default_keep_monthly: 12 + }); + + const fetchSettings = async () => { + setLoading(true); + setErrorMsg(null); + try { + const res = await api.getSettings(); + setFormData(res); + } catch (err: any) { + setErrorMsg(`Error al cargar configuraciones: ${err.message}`); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSettings(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setErrorMsg(null); + setSuccessMsg(null); + try { + const res = await api.updateSettings(formData); + setFormData(res); + setSuccessMsg('✓ Configuraciones globales guardadas y aplicadas con éxito.'); + onRefresh(); // Refresh stats in App.tsx + setTimeout(() => setSuccessMsg(null), 5000); + } catch (err: any) { + setErrorMsg(`Error al guardar configuraciones: ${err.message}`); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ + Cargando configuraciones globales... +
+ ); + } + + return ( +
+
+

Configuración Global del Sistema

+

+ Personaliza los parámetros del servidor, límites de almacenamiento y políticas de retención +

+
+ + {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {successMsg && ( +
+ + {successMsg} +
+ )} + +
+ {/* Section 1: Storage Location */} +
+
+ +

1. Ubicación de Almacenamiento

+
+
+ + setFormData({ ...formData, storage_root: e.target.value })} + /> +

+ Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente. + Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio. +

+
+
+ + {/* Section 2: Quotas */} +
+
+ +

2. Límites de Capacidad y Cuotas

+
+
+
+ + setFormData({ ...formData, global_quota_gb: Number(e.target.value) })} + /> + + Equivale a: {formatBytes(formData.global_quota_gb * 1024 * 1024 * 1024)}. Límite máximo del sistema. + +
+ +
+ + setFormData({ ...formData, default_client_quota_gb: Number(e.target.value) })} + /> + + Se asigna automáticamente a los nuevos agentes al registrarse. + +
+
+
+ + {/* Section 3: Retention Defaults */} +
+
+ +

3. Políticas de Retención por Defecto

+
+

+ Establece los tiempos de retención predeterminados que se cargarán al crear nuevos trabajos de backup en el dashboard. +

+
+
+ + setFormData({ ...formData, default_keep_daily: Number(e.target.value) })} + /> +
+
+ + setFormData({ ...formData, default_keep_weekly: Number(e.target.value) })} + /> +
+
+ + setFormData({ ...formData, default_keep_monthly: Number(e.target.value) })} + /> +
+
+
+ + {/* Form Actions */} +
+ +
+
+
+ ); +}; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 56856c3..46447c2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -22,6 +22,7 @@ export interface ClientItem { id: number; client_code: string; name: string; + alias?: string; hostname?: string; os_info?: string; ip_address?: string; @@ -80,6 +81,15 @@ export interface EventLogItem { message: string; } +export interface SystemSettingsResponse { + storage_root: string; + global_quota_gb: number; + default_client_quota_gb: number; + default_keep_daily: number; + default_keep_weekly: number; + default_keep_monthly: number; +} + export const getAuthToken = (): string | null => { return localStorage.getItem('oed_token'); }; @@ -156,8 +166,21 @@ export const api = { }), revokeClient: (clientId: number) => request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }), + generateReRegisterCode: (clientId: number) => + request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }), deleteClient: (clientId: number) => request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }), + updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) => + request(`/clients/${clientId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + getSettings: () => request('/settings'), + updateSettings: (data: Partial) => + request('/settings', { + method: 'PUT', + body: JSON.stringify(data), + }), // Jobs getJobs: (clientId?: number) => @@ -189,6 +212,25 @@ export const api = { if (jobId) params.append('job_id', jobId.toString()); return request(`/backups?${params.toString()}`); }, + downloadBackup: async (backup: BackupFileItem) => { + const token = getAuthToken(); + const response = await fetch(`${API_BASE}/backups/${backup.id}/download`, { + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Network error' })); + throw new Error(errorData.detail || `Download failed with status ${response.status}`); + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = backup.filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, deleteBackup: (backupId: number) => request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }), diff --git a/windows-agent/agent/config.py b/windows-agent/agent/config.py index c0dd12a..425a11f 100644 --- a/windows-agent/agent/config.py +++ b/windows-agent/agent/config.py @@ -17,9 +17,11 @@ CONFIG_FILE = AGENT_HOME / "config.json" class LocalFolderJob(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8]) + job_id: Optional[int] = None name: str source_path: str file_patterns: str = "*.bak,*.mdf" + schedule_cron: str = "daily" schedule_interval_minutes: int = 60 min_stable_seconds: int = 60 is_active: bool = True diff --git a/windows-agent/agent/service.py b/windows-agent/agent/service.py index 0e3f859..8d8e5fa 100644 --- a/windows-agent/agent/service.py +++ b/windows-agent/agent/service.py @@ -20,6 +20,36 @@ logging.basicConfig( ) logger = logging.getLogger("OnEverAgent") +def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool: + if not schedule_str: + return True + if not last_run_str: + return True + + try: + # Try parsing ISO (from server) or standard YYYY-MM-DD HH:MM:SS (local) + if "T" in last_run_str: + last_run = datetime.fromisoformat(last_run_str.replace("Z", "+00:00")) + else: + last_run = datetime.strptime(last_run_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + except Exception: + return True + + now = datetime.now(timezone.utc) + delta = now - last_run + + sched = schedule_str.lower().strip() + if sched == "hourly": + return delta.total_seconds() >= 3600 + elif sched == "daily": + return delta.total_seconds() >= 86400 + elif sched == "weekly": + return delta.total_seconds() >= 86400 * 7 + elif sched == "monthly": + return delta.total_seconds() >= 86400 * 30 + else: + return True + class AgentDaemon: """Background service worker for Windows: handles heartbeats, job polling and scheduled backups.""" @@ -35,6 +65,7 @@ class AgentDaemon: self.config = config or load_config() self.running = False self.uploader = ChunkUploader(self.config) + self.last_server_jobs = [] self._heartbeat_thread: Optional[threading.Thread] = None self._worker_thread: Optional[threading.Thread] = None @@ -100,53 +131,122 @@ class AgentDaemon: time.sleep(30) - def _run_backup_cycle(self): + def _run_backup_cycle(self, force: bool = False): self.config = load_config() self.uploader.config = self.config - # 1. Fetch server-assigned jobs + # 1. Fetch server-assigned jobs and run bidirectional sync server_jobs = [] + sync_success = False try: base_url = self.config.server_url.rstrip("/") - with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client: + with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client: resp = client.get("/api/jobs/agent/assigned") if resp.status_code == 200: server_jobs = resp.json() - except Exception: - pass + self.last_server_jobs = server_jobs + sync_success = True + except Exception as e: + logger.warning(f"Could not fetch server jobs: {e}") - # 2. Combine server jobs + user local folders - all_jobs = [] - for sj in server_jobs: - all_jobs.append({ - "job_id": sj.get("id"), - "name": sj.get("name"), - "source_path": sj.get("source_path"), - "file_patterns": sj.get("file_patterns", "*.*"), - "min_stable_seconds": sj.get("min_stable_time_seconds", 60) - }) + config_changed = False - for lj in self.config.local_folders: - if lj.is_active: - all_jobs.append({ - "job_id": None, - "local_job_id": lj.id, - "name": lj.name, - "source_path": lj.source_path, - "file_patterns": lj.file_patterns, - "min_stable_seconds": lj.min_stable_seconds - }) + if sync_success: + # A. Sync Server -> Local + server_job_ids = {sj["id"] for sj in server_jobs} + + # Remove local jobs that have a job_id but are not on the server anymore (deleted on server) + local_jobs_to_keep = [] + for lj in self.config.local_folders: + if lj.job_id is None: + # New local job, keep it so we register it next + local_jobs_to_keep.append(lj) + elif lj.job_id in server_job_ids: + # Keep it and update local properties from server + sj = next(x for x in server_jobs if x["id"] == lj.job_id) + lj.name = sj.get("name", lj.name) + lj.source_path = sj.get("source_path", lj.source_path) + lj.file_patterns = sj.get("file_patterns", lj.file_patterns) + lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron) + lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds) + + # Also sync last_run_at from server if available and newer + if sj.get("last_run_at"): + lj.last_backup_at = sj["last_run_at"].replace("T", " ")[:19] + lj.last_status = sj.get("status", lj.last_status) + + local_jobs_to_keep.append(lj) + else: + # Deleted on server, don't keep it + config_changed = True + + self.config.local_folders = local_jobs_to_keep - # 3. Process jobs - for job in all_jobs: - source_path = job["source_path"] - file_patterns = job["file_patterns"] - min_stable = job["min_stable_seconds"] - job_name = job["name"] + # Add server jobs that are missing locally + local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None} + for sj in server_jobs: + if sj["id"] not in local_job_ids: + new_job = LocalFolderJob( + job_id=sj["id"], + name=sj["name"], + source_path=sj["source_path"], + file_patterns=sj["file_patterns"], + schedule_cron=sj["schedule_cron"], + min_stable_seconds=sj["min_stable_time_seconds"], + last_status=sj.get("status", "En espera") + ) + if sj.get("last_run_at"): + new_job.last_backup_at = sj["last_run_at"].replace("T", " ")[:19] + self.config.local_folders.append(new_job) + config_changed = True + + # B. Sync Local -> Server (Register new local folders on the server) + for lj in self.config.local_folders: + if lj.job_id is None: + try: + base_url = self.config.server_url.rstrip("/") + payload = { + "name": lj.name, + "source_path": lj.source_path, + "file_patterns": lj.file_patterns, + "schedule_cron": lj.schedule_cron, + "min_stable_time_seconds": lj.min_stable_seconds + } + resp = httpx.post(f"{base_url}/api/jobs/agent/register", headers=self._get_headers(), json=payload, timeout=10.0) + if resp.status_code == 200: + data = resp.json() + lj.job_id = data["id"] + config_changed = True + logger.info(f"Registered local job '{lj.name}' on server with ID {lj.job_id}") + except Exception as e: + logger.warning(f"Could not register local job '{lj.name}' on server: {e}") + + if config_changed: + save_config(self.config) + + # 2. Process active jobs + for job in self.config.local_folders: + if not job.is_active: + continue + + # Check if job is due or forced + if not force and not is_job_due(job.schedule_cron, job.last_backup_at): + continue + + source_path = job.source_path + file_patterns = job.file_patterns + min_stable = job.min_stable_seconds + job_name = job.name + + # Skip if path does not exist + if not Path(source_path).exists(): + logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.") + continue scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable) files = scanner.scan() + # Track files successfully backed up in this run for filepath in files: if not is_file_stable(filepath, min_stable_seconds=min_stable): logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.") @@ -159,7 +259,6 @@ class AgentDaemon: file_size_bytes = filepath.stat().st_size logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)") - # Single notification when the process starts if self.on_started: self.on_started(filepath.name, file_size_bytes) @@ -170,24 +269,22 @@ class AgentDaemon: try: res = self.uploader.upload_file( filepath, - job_id=job.get("job_id"), + job_id=job.job_id, progress_callback=on_chunk_progress ) logger.info(f"Successfully backed up {filepath.name}!") - # Update local folder last backup status - if "local_job_id" in job: - for folder in self.config.local_folders: - if folder.id == job["local_job_id"]: - folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - folder.last_status = "Backup Exitoso" - save_config(self.config) - - # Single notification when the process finishes if self.on_completed: self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes) except Exception as ex: logger.error(f"Failed to backup {filepath.name}: {str(ex)}") + job.last_status = "Error" + save_config(self.config) if self.on_error: self.on_error(filepath.name, str(ex)) + + # Update job state in config after checking directory + job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error" + save_config(self.config) diff --git a/windows-agent/agent_app_pyqt.py b/windows-agent/agent_app_pyqt.py index 5513531..758b2c8 100644 --- a/windows-agent/agent_app_pyqt.py +++ b/windows-agent/agent_app_pyqt.py @@ -4,11 +4,14 @@ import time import socket import platform import threading +import logging from pathlib import Path from typing import Optional, List import httpx -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize +logger = logging.getLogger("OnEverAgentGUI") + +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer from PyQt6.QtGui import QIcon, QFont, QColor, QAction from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, @@ -233,11 +236,10 @@ class AddFolderDialog(QDialog): self.txt_patterns.setText("*.bak,*.mdf") form.addRow("Filtros de archivo:", self.txt_patterns) - self.spin_interval = QSpinBox() - self.spin_interval.setRange(5, 1440) - self.spin_interval.setValue(60) - self.spin_interval.setSuffix(" min") - form.addRow("Frecuencia de sondeo:", self.spin_interval) + self.cmb_schedule = QComboBox() + self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"]) + self.cmb_schedule.setCurrentText("daily") + form.addRow("Planificación (Schedule):", self.cmb_schedule) self.spin_stable = QSpinBox() self.spin_stable.setRange(10, 600) @@ -279,7 +281,7 @@ class AddFolderDialog(QDialog): name=self.txt_name.text().strip(), source_path=self.txt_path.text().strip(), file_patterns=self.txt_patterns.text().strip() or "*.*", - schedule_interval_minutes=self.spin_interval.value(), + schedule_cron=self.cmb_schedule.currentText(), min_stable_seconds=self.spin_stable.value() ) @@ -311,6 +313,12 @@ class OnEverDriveMainWindow(QMainWindow): self._init_ui() self._init_tray() + # Timer to refresh folders list in GUI with server-assigned jobs periodically + self.timer_refresh = QTimer(self) + self.timer_refresh.setInterval(10000) # every 10 seconds + self.timer_refresh.timeout.connect(self._refresh_folders_table) + self.timer_refresh.start() + if self.config.device_id: self.daemon.start() @@ -611,7 +619,13 @@ class OnEverDriveMainWindow(QMainWindow): self._build_tray_menu() def _on_tray_activated(self, reason): - if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger: + # Safely convert to int to bypass PyQt6 enum comparison bugs + try: + val = int(reason) + except Exception: + val = reason.value if hasattr(reason, 'value') else reason + + if val in (2, 3): # 2: DoubleClick, 3: Trigger self.showNormal() self.activateWindow() @@ -650,13 +664,34 @@ class OnEverDriveMainWindow(QMainWindow): def _refresh_folders_table(self): self.config = load_config() - self.tbl_folders.setRowCount(len(self.config.local_folders)) - for row, job in enumerate(self.config.local_folders): + server_jobs = getattr(self.daemon, "last_server_jobs", []) + + # Combine local folders and server-assigned jobs + total_rows = len(server_jobs) + len(self.config.local_folders) + self.tbl_folders.setRowCount(total_rows) + + # Show server jobs first + row = 0 + for job in server_jobs: + self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.get("name", ""))) + self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.get("source_path", ""))) + self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.get("file_patterns", ""))) + self.tbl_folders.setItem(row, 3, QTableWidgetItem(job.get("schedule_cron", ""))) + + status_item = QTableWidgetItem("Sincronizado (Web)") + status_item.setForeground(QColor("#34D399")) # Light green color in dark mode + self.tbl_folders.setItem(row, 4, status_item) + row += 1 + + # Then show local folders + for job in self.config.local_folders: self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name)) self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path)) self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns)) - self.tbl_folders.setItem(row, 3, QTableWidgetItem(f"{job.schedule_interval_minutes} min")) - self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera")) + self.tbl_folders.setItem(row, 3, QTableWidgetItem(getattr(job, "schedule_cron", "daily"))) + + self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera (Local)")) + row += 1 def _delete_selected_folder(self): row = self.tbl_folders.currentRow() @@ -665,11 +700,29 @@ class OnEverDriveMainWindow(QMainWindow): return job_name = self.tbl_folders.item(row, 0).text() - reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?") + reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el trabajo de backup '{job_name}' del Agente y del Servidor?") if reply == QMessageBox.StandardButton.Yes: self.config = load_config() - if row < len(self.config.local_folders): - self.config.local_folders.pop(row) + path_val = self.tbl_folders.item(row, 1).text() + matched_jobs = [f for f in self.config.local_folders if f.source_path == path_val] + + if matched_jobs: + job = matched_jobs[0] + if job.job_id: + try: + base_url = self.config.server_url.rstrip("/") + headers = { + "X-Device-Id": self.config.device_id, + "X-Device-Token": self.config.device_token + } + resp = httpx.delete(f"{base_url}/api/jobs/agent/{job.job_id}", headers=headers, timeout=5.0) + if resp.status_code != 200: + QMessageBox.warning(self, "Advertencia", f"No se pudo eliminar en el servidor: {resp.text}.\nSe eliminará localmente.") + except Exception as e: + logger.warning(f"Failed to delete job on server: {e}") + QMessageBox.warning(self, "Advertencia", f"No se pudo contactar al servidor: {e}.\nSe eliminará localmente.") + + self.config.local_folders = [f for f in self.config.local_folders if f.source_path != path_val] save_config(self.config) self._refresh_folders_table() self._update_header_status() @@ -735,7 +788,7 @@ class OnEverDriveMainWindow(QMainWindow): QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.") return - threading.Thread(target=self.daemon._run_backup_cycle, daemon=True).start() + threading.Thread(target=lambda: self.daemon._run_backup_cycle(force=True), daemon=True).start() self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...") # --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---