commit 1bfb808c799d845b4d957d097dd4ccb7dcc7615c Author: oemspot Date: Thu Aug 13 19:33:44 2026 -0300 feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a4af5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv/ + +# Node / Frontend +node_modules/ +frontend/node_modules/ +frontend/dist/ +dist/ +build/ +.next/ +out/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# PyInstaller / Executables +windows-agent/build/ +windows-agent/dist/ +build/ +*.spec + +# Storage & Databases +backend/storage/backups/ +*.db +*.sqlite +*.sqlite3 +data/ +*.bak +*.mdf +*.ldf + +# Environments & OS +.env +.env.local +.DS_Store +Thumbs.db +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5090770 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# OnEver Drive + +Plataforma empresarial centralizada de backup y sincronización para entornos Windows sobre infraestructura **Proxmox VE (Contenedores LXC Debian 12/13)**. + +--- + +## 🚀 Arquitectura General + +```text + ┌────────────────────────────────────────────────────────┐ + │ CLIENTES WINDOWS (10/11/Server) │ + │ │ + │ [ 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) │ + │ └── Verificación de integridad SHA-256 local │ + └──────────────────────────┬─────────────────────────────┘ + │ HTTPS / TLS (Chunks + JSON) + ▼ + ┌────────────────────────────────────────────────────────┐ + │ SERVIDOR PROXMOX VE (LXC Debian) │ + │ │ + │ [ Nginx Proxy Inverso + SSL / Let's Encrypt ] │ + │ ├── Frontend Web Dashboard (React 19 + TypeScript) │ + │ ├── Backend REST API (FastAPI Asíncrono) │ + │ ├── WebSockets (Telemetría de subidas en tiempo real) │ + │ ├── Ensamblado Streaming SHA-256 (Bajo consumo RAM) │ + │ ├── Políticas de Retención (Diaria/Semanal/Mensual) │ + │ └── Base de Datos (PostgreSQL 16 / SQLite dev) │ + └────────────────────────────────────────────────────────┘ +``` + +--- + +## 📦 Estructura del Repositorio + +- **`backend/`**: API REST FastAPI con autenticación JWT, registro de dispositivos por código temporal, motor de chunks con reanudación, políticas de retención y WebSockets. +- **`frontend/`**: Dashboard web moderno (Vite + React 19 + TypeScript) con telemetría en vivo, gestión de clientes, creación de trabajos y explorador de restauración. +- **`windows-agent/`**: Agente nativo en PyQt6 para Windows con System Tray, selector de carpetas, notificaciones discretas y empaquetado en binario `.exe` (PyInstaller). +- **`deployment/`**: Scripts de aprovisionamiento automatizado en contenedores nativos **Debian 12/13 LXC en Proxmox VE**, configuración de Nginx, SSL y mantenimiento de backups. +- **`docs/`**: Documentación técnica, manual del agente Windows, arquitectura del motor de chunks y guía de despliegue en Proxmox. +- **`tests/`**: Suite automatizada con Pytest para verificar reanudación ante microcortes, aislamiento de clientes y detección de bloqueos. + +--- + +## 🛠️ Requisitos de Despliegue en Proxmox VE + +El backend y frontend se despliegan en un **Contenedor LXC Debian 12/13** sin dependencias de Docker: + +```bash +# 1. Crear el contenedor en el host Proxmox +bash deployment/proxmox/01-create-lxc-pve-host.sh + +# 2. Instalar el entorno, PostgreSQL, Nginx y Backend dentro del LXC +pct enter 100 +bash /root/deployment/proxmox/02-install-backend-debian.sh + +# 3. Configurar certificados SSL +bash /root/deployment/proxmox/03-configure-ssl.sh +``` + +--- + +## 💻 Agente de Windows (.EXE) + +El agente se ejecuta en el área de notificaciones (System Tray) y cuenta con una interfaz gráfica en PyQt6 para apuntar al servidor y seleccionar las carpetas locales: + +```powershell +# Ejecutar agente desde binario compilado +windows-agent\dist\OnEverDriveAgent-Standalone.exe + +# O compilar nuevamente desde el código fuente +python windows-agent/build_exe.py +``` + +--- + +## 🔒 Seguridad y Multi-Inquilino + +- **Aislamiento por Dispositivo**: Cada máquina Windows tiene un `device_id` y `device_token` único generado criptográficamente. +- **Validación SHA-256**: Cada bloque de 4MB y el archivo final ensamblado se verifican mediante streaming de hash SHA-256. +- **Protección SQL Server**: Detección de archivos en uso para evitar transferencias incompletas de volcados `.bak`. diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..2de449b --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.core.security import verify_password, get_password_hash, create_access_token +from app.models.models import User +from app.schemas.schemas import LoginRequest, TokenResponse, UserResponse +from app.api.deps import get_current_user +from app.services.event_service import log_event + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + +@router.post("/login", response_model=TokenResponse) +async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == credentials.email.strip().lower())) + user = result.scalar_one_or_none() + + if not user or not verify_password(credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) + + access_token = create_access_token(subject=user.id, role=user.role) + + await log_event( + db=db, + event_type="LOGIN", + message=f"User {user.email} logged in successfully.", + severity="INFO", + user_email=user.email + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "role": user.role + } + } + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user + +@router.post("/seed-admin") +async def seed_admin(db: AsyncSession = Depends(get_db)): + """Creates default admin if no users exist in the database.""" + result = await db.execute(select(User)) + first_user = result.scalar_one_or_none() + if first_user: + return {"message": "Admin already exists"} + + admin = User( + email="admin@oneverdrive.local", + hashed_password=get_password_hash("Admin1234!"), + full_name="System Administrator", + role="ADMIN", + is_active=True + ) + db.add(admin) + await db.commit() + return {"message": "Default admin created: admin@oneverdrive.local / Admin1234!"} diff --git a/backend/app/api/backups.py b/backend/app/api/backups.py new file mode 100644 index 0000000..58af427 --- /dev/null +++ b/backend/app/api/backups.py @@ -0,0 +1,116 @@ +import os +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.models.models import User, BackupFile, Client +from app.schemas.schemas import BackupFileResponse +from app.api.deps import get_current_user, require_admin +from app.storage.local import storage_provider +from app.services.event_service import log_event + +router = APIRouter(prefix="/backups", tags=["Backups & Restore"]) + +@router.get("", response_model=List[BackupFileResponse]) +async def list_backups( + client_id: Optional[int] = None, + job_id: Optional[int] = None, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + query = select(BackupFile).where(BackupFile.is_active == True) + if client_id: + query = query.where(BackupFile.client_id == client_id) + if job_id: + query = query.where(BackupFile.job_id == job_id) + query = query.order_by(desc(BackupFile.created_at)).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + +@router.get("/{backup_id}", response_model=BackupFileResponse) +async def get_backup( + backup_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id)) + bf = res.scalar_one_or_none() + if not bf: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found") + return bf + +@router.get("/{backup_id}/download") +async def download_backup( + backup_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Allows downloading a backup file directly from the Web interface.""" + res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id)) + bf = res.scalar_one_or_none() + if not bf or not bf.is_active: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found") + + try: + abs_path = await storage_provider.get_file_path(bf.relative_path) + except Exception as ex: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex)) + + await log_event( + db=db, + event_type="RESTORE_STARTED", + message=f"Backup download initiated for '{bf.filename}' ({bf.file_size / (1024*1024):.2f} MB).", + severity="INFO", + client_id=bf.client_id, + job_id=bf.job_id, + user_email=current_user.email + ) + + return FileResponse( + path=abs_path, + filename=bf.filename, + media_type="application/octet-stream" + ) + +@router.delete("/{backup_id}") +async def delete_backup( + backup_id: int, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id)) + bf = res.scalar_one_or_none() + if not bf: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found") + + try: + await storage_provider.delete_backup_file(bf.relative_path) + except Exception: + pass + + bf.is_active = False + + # Update client storage counter + client_res = await db.execute(select(Client).where(Client.id == bf.client_id)) + client = client_res.scalar_one_or_none() + if client: + client.storage_used_bytes = max(0, client.storage_used_bytes - bf.file_size) + + await db.commit() + + await log_event( + db=db, + event_type="FILE_DELETED", + message=f"Manual deletion of backup '{bf.filename}'.", + severity="WARNING", + client_id=bf.client_id, + job_id=bf.job_id, + user_email=admin_user.email + ) + + return {"message": "Backup file deleted"} diff --git a/backend/app/api/clients.py b/backend/app/api/clients.py new file mode 100644 index 0000000..1558979 --- /dev/null +++ b/backend/app/api/clients.py @@ -0,0 +1,245 @@ +import uuid +from datetime import datetime, timedelta, timezone +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc + +from app.core.database import get_db +from app.core.security import generate_registration_code, generate_device_token, hash_token +from app.models.models import User, Client, ClientCredential, RegistrationCode +from app.schemas.schemas import ( + ClientResponse, ClientRegisterRequest, ClientRegisterResponse, + RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest +) +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 + +router = APIRouter(prefix="/clients", tags=["Clients"]) + +@router.get("", response_model=List[ClientResponse]) +async def list_clients( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute(select(Client).order_by(desc(Client.created_at))) + return result.scalars().all() + +@router.post("/registration-code", response_model=RegistrationCodeResponse) +async def create_registration_code( + payload: RegistrationCodeCreate, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + code_str = generate_registration_code() + expires = datetime.now(timezone.utc) + timedelta(hours=payload.expires_in_hours) + + reg_code = RegistrationCode( + code=code_str, + client_name_hint=payload.client_name_hint, + 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 registration code {code_str} (hint: {payload.client_name_hint or 'None'}).", + severity="INFO", + user_email=admin_user.email + ) + + return reg_code + +@router.post("/register", response_model=ClientRegisterResponse) +async def register_client( + payload: ClientRegisterRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """Called by the Windows Agent during initial setup to register against the server.""" + # Find valid registration code + now = datetime.now(timezone.utc) + res = await db.execute( + select(RegistrationCode).where( + RegistrationCode.code == payload.registration_code.strip().upper(), + RegistrationCode.is_used == False, + RegistrationCode.expires_at > now + ) + ) + reg_code = res.scalar_one_or_none() + if not reg_code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + 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() + + # Generate device unique ID and secret token + device_id = str(uuid.uuid4()) + device_token = generate_device_token() + token_hash = hash_token(device_token) + + credential = ClientCredential( + client_id=client.id, + device_id=device_id, + token_hash=token_hash, + name=f"{payload.hostname} Agent", + is_revoked=False, + created_at=now, + last_used_at=now + ) + db.add(credential) + + # Mark registration code as used + reg_code.is_used = True + await db.commit() + + await log_event( + db=db, + event_type="CLIENT_REGISTERED", + message=f"Windows client registered: {client.name} ({client.client_code}, Hostname: {client.hostname}, IP: {client_ip})", + severity="INFO", + client_id=client.id, + ip_address=client_ip, + details={"device_id": device_id, "os_info": payload.os_info} + ) + + await ws_manager.broadcast("CLIENT_REGISTERED", { + "id": client.id, + "client_code": client.client_code, + "name": client.name, + "hostname": client.hostname, + "status": client.status + }) + + return { + "client_code": client.client_code, + "device_id": device_id, + "device_token": device_token, + "name": client.name, + "server_time": now + } + +@router.post("/{client_id}/heartbeat") +async def client_heartbeat( + client_id: int, + payload: ClientHeartbeatRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """Heartbeat endpoint invoked periodically by the Windows Agent.""" + if current_client.id != client_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client ID mismatch") + + now = datetime.now(timezone.utc) + current_client.last_seen_at = now + current_client.status = payload.status + if payload.agent_version: + current_client.agent_version = payload.agent_version + + client_ip = payload.ip_address or (request.client.host if request.client else None) + if client_ip: + current_client.ip_address = client_ip + + await db.commit() + + await ws_manager.broadcast("CLIENT_HEARTBEAT", { + "client_id": current_client.id, + "status": current_client.status, + "last_seen_at": now.isoformat() + }) + + return {"status": "ok", "server_time": now} + +@router.get("/{client_id}", response_model=ClientResponse) +async def get_client( + client_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + 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") + return client + +@router.post("/{client_id}/revoke") +async def revoke_client_credentials( + client_id: int, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + """Revokes all active authentication tokens for a client.""" + res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id)) + creds = res.scalars().all() + for cred in creds: + cred.is_revoked = True + + client_res = await db.execute(select(Client).where(Client.id == client_id)) + client = client_res.scalar_one_or_none() + if client: + client.status = "OFFLINE" + + await db.commit() + + await log_event( + db=db, + event_type="CREDENTIALS_REVOKED", + message=f"Revoked credentials for client ID {client_id}.", + severity="WARNING", + client_id=client_id, + user_email=admin_user.email + ) + + return {"message": "Client credentials successfully revoked"} + +@router.delete("/{client_id}") +async def delete_client( + client_id: int, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + 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") + + await db.delete(client) + await db.commit() + + await log_event( + db=db, + event_type="CLIENT_DELETED", + message=f"Deleted client {client.name} ({client.client_code}).", + severity="WARNING", + user_email=admin_user.email + ) + + return {"message": f"Client {client.client_code} deleted"} diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..ada415f --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,87 @@ +from typing import Optional +from fastapi import Depends, HTTPException, status, Header +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import decode_access_token, hash_token +from app.models.models import User, Client, ClientCredential + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + +async def get_current_user( + token: Optional[str] = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> User: + """Authenticates web UI users via JWT Bearer token.""" + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"} + ) + payload = decode_access_token(token) + if not payload or "sub" not in payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired authentication token", + headers={"WWW-Authenticate": "Bearer"} + ) + + user_id = int(payload["sub"]) + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or deactivated" + ) + return user + +async def require_admin(user: User = Depends(get_current_user)) -> User: + """Ensures current user has ADMIN role.""" + if user.role != "ADMIN": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privilege required" + ) + return user + +async def get_current_client( + x_device_id: Optional[str] = Header(None, alias="X-Device-Id"), + x_device_token: Optional[str] = Header(None, alias="X-Device-Token"), + db: AsyncSession = Depends(get_db) +) -> Client: + """Authenticates Windows Agent devices via individual device ID and secret token.""" + if not x_device_id or not x_device_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Device authentication headers (X-Device-Id, X-Device-Token) required" + ) + + token_hash = hash_token(x_device_token) + result = await db.execute( + select(ClientCredential) + .where( + ClientCredential.device_id == x_device_id, + ClientCredential.token_hash == token_hash, + ClientCredential.is_revoked == False + ) + ) + credential = result.scalar_one_or_none() + if not credential: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or revoked device credentials" + ) + + client_res = await db.execute(select(Client).where(Client.id == credential.client_id, Client.is_active == True)) + client = client_res.scalar_one_or_none() + if not client: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Client device not found or inactive" + ) + + return client diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..b857e4c --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,32 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.models.models import EventLog, User +from app.schemas.schemas import EventLogResponse +from app.api.deps import get_current_user + +router = APIRouter(prefix="/events", tags=["Audit & Events"]) + +@router.get("", response_model=List[EventLogResponse]) +async def list_events( + client_id: Optional[int] = None, + job_id: Optional[int] = None, + event_type: Optional[str] = None, + limit: int = Query(50, le=200), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + query = select(EventLog) + if client_id: + query = query.where(EventLog.client_id == client_id) + if job_id: + query = query.where(EventLog.job_id == job_id) + if event_type: + query = query.where(EventLog.event_type == event_type) + + query = query.order_by(desc(EventLog.timestamp)).limit(limit) + result = await db.execute(query) + return result.scalars().all() diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py new file mode 100644 index 0000000..2dc648c --- /dev/null +++ b/backend/app/api/jobs.py @@ -0,0 +1,175 @@ +from typing import List, Optional +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +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.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 + +router = APIRouter(prefix="/jobs", tags=["Backup Jobs"]) + +@router.get("", response_model=List[JobResponse]) +async def list_jobs( + client_id: Optional[int] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + query = select(BackupJob) + if client_id: + query = query.where(BackupJob.client_id == client_id) + query = query.order_by(desc(BackupJob.created_at)) + result = await db.execute(query) + return result.scalars().all() + +@router.get("/agent/assigned", response_model=List[JobResponse]) +async def get_agent_jobs( + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """Called by the Windows Agent to query its active backup jobs.""" + query = select(BackupJob).where(BackupJob.client_id == current_client.id, BackupJob.is_active == True) + result = await db.execute(query) + return result.scalars().all() + +@router.post("", response_model=JobResponse) +async def create_job( + payload: JobCreate, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + # Verify client exists + client_res = await db.execute(select(Client).where(Client.id == payload.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") + + 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=payload.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"Created backup job '{job.name}' ({job.job_code}) for client {client.name}.", + severity="INFO", + client_id=client.id, + job_id=job.id, + user_email=admin_user.email + ) + + return job + +@router.get("/{job_id}", response_model=JobResponse) +async def get_job( + job_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + return job + +@router.put("/{job_id}", response_model=JobResponse) +async def update_job( + job_id: int, + payload: JobUpdate, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(job, field, value) + + await db.commit() + await db.refresh(job) + + await log_event( + db=db, + event_type="CONFIG_CHANGED", + message=f"Updated backup job '{job.name}' ({job.job_code}).", + severity="INFO", + client_id=job.client_id, + job_id=job.id, + user_email=admin_user.email + ) + + return job + +@router.delete("/{job_id}") +async def delete_job( + job_id: int, + db: AsyncSession = Depends(get_db), + admin_user: User = Depends(require_admin) +): + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + await db.delete(job) + await db.commit() + + await log_event( + db=db, + event_type="JOB_DELETED", + message=f"Deleted backup job {job.job_code}.", + severity="WARNING", + client_id=job.client_id, + user_email=admin_user.email + ) + + return {"message": f"Job {job.job_code} deleted"} + +@router.post("/{job_id}/trigger") +async def trigger_job( + job_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Notifies the connected agent to start executing this backup job immediately.""" + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + job.status = "QUEUED" + await db.commit() + + # Broadcast event so the agent / web UI knows the job was triggered + await ws_manager.broadcast("JOB_TRIGGERED", { + "job_id": job.id, + "job_code": job.job_code, + "client_id": job.client_id, + "timestamp": datetime.now(timezone.utc).isoformat() + }) + + return {"message": f"Job {job.job_code} triggered"} diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py new file mode 100644 index 0000000..0d515aa --- /dev/null +++ b/backend/app/api/stats.py @@ -0,0 +1,81 @@ +from datetime import datetime, timezone, timedelta +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db +from app.models.models import Client, BackupJob, BackupSession, User +from app.schemas.schemas import DashboardStatsResponse +from app.api.deps import get_current_user +from app.storage.local import storage_provider + +router = APIRouter(prefix="/stats", tags=["Dashboard Statistics"]) + +@router.get("", response_model=DashboardStatsResponse) +async def get_dashboard_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Clients aggregation + total_clients_res = await db.execute(select(func.count(Client.id)).where(Client.is_active == True)) + total_clients = total_clients_res.scalar() or 0 + + # Consider online if last_seen_at was in the last 5 minutes + cutoff_online = datetime.now(timezone.utc) - timedelta(minutes=5) + online_clients_res = await db.execute( + select(func.count(Client.id)).where( + Client.is_active == True, + Client.status == "ONLINE", + Client.last_seen_at >= cutoff_online + ) + ) + online_clients = online_clients_res.scalar() or 0 + offline_clients = max(0, total_clients - online_clients) + + # Jobs count + total_jobs_res = await db.execute(select(func.count(BackupJob.id)).where(BackupJob.is_active == True)) + total_jobs = total_jobs_res.scalar() or 0 + + # Backups today + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + backups_today_res = await db.execute( + select(func.count(BackupSession.id)).where(BackupSession.started_at >= today_start) + ) + backups_today = backups_today_res.scalar() or 0 + + backups_success_res = await db.execute( + select(func.count(BackupSession.id)).where( + BackupSession.started_at >= today_start, + BackupSession.status == "SUCCESS" + ) + ) + backups_success = backups_success_res.scalar() or 0 + + backups_failed_res = await db.execute( + select(func.count(BackupSession.id)).where( + BackupSession.started_at >= today_start, + BackupSession.status == "FAILED" + ) + ) + backups_failed = backups_failed_res.scalar() or 0 + + # Active uploads + active_uploads_res = await db.execute( + select(func.count(BackupSession.id)).where(BackupSession.status.in_(["PENDING", "UPLOADING", "ASSEMBLING"])) + ) + active_uploads = active_uploads_res.scalar() or 0 + + # Storage metrics + storage_stats = await storage_provider.get_storage_stats() + + return { + "total_clients": total_clients, + "online_clients": online_clients, + "offline_clients": offline_clients, + "total_jobs": total_jobs, + "backups_today_count": backups_today, + "backups_today_success": backups_success, + "backups_today_failed": backups_failed, + "active_uploads_count": active_uploads, + "storage": storage_stats + } diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..c67ef33 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,153 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Header, Request, Query, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.models.models import Client, BackupSession +from app.schemas.schemas import ( + UploadSessionInitRequest, UploadSessionInitResponse, + UploadSessionStatusResponse, ChunkUploadResponse, + UploadSessionCompleteResponse +) +from app.api.deps import get_current_client +from app.services.upload_service import ( + create_or_resume_session, process_chunk_upload, + get_session_status_info, complete_session +) + +router = APIRouter(prefix="/upload", tags=["Chunk Upload Engine"]) + +@router.post("/session", response_model=UploadSessionInitResponse) +async def init_session( + payload: UploadSessionInitRequest, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """ + Initializes a new upload session or resumes an existing incomplete session + for the specified file. Returns list of previously received chunks so the agent + only transmits the remaining blocks. + """ + session, received_chunks = await create_or_resume_session( + db=db, + client=current_client, + filename=payload.filename, + file_size=payload.file_size, + sha256_full=payload.sha256, + chunk_size=payload.chunk_size, + job_id=payload.job_id + ) + + return { + "session_code": session.session_code, + "filename": session.filename, + "file_size": session.file_size, + "chunk_size": session.chunk_size, + "total_chunks": session.total_chunks, + "received_chunks": received_chunks, + "status": session.status + } + +@router.post("/{session_code}/chunk", response_model=ChunkUploadResponse) +async def upload_chunk( + session_code: str, + request: Request, + chunk_index: int = Query(..., description="0-indexed chunk number"), + chunk_sha256: Optional[str] = Query(None, description="SHA-256 hash of this specific chunk"), + x_chunk_index: Optional[int] = Header(None, alias="X-Chunk-Index"), + x_chunk_sha256: Optional[str] = Header(None, alias="X-Chunk-SHA256"), + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """ + Receives and stores a single chunk of data for an active upload session. + Accepts raw binary body directly via streaming. + """ + idx = x_chunk_index if x_chunk_index is not None else chunk_index + sha = x_chunk_sha256 or chunk_sha256 + + res = await db.execute( + select(BackupSession).where( + BackupSession.session_code == session_code, + BackupSession.client_id == current_client.id + ) + ) + session = res.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found") + + chunk_data = await request.body() + if not chunk_data: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty chunk payload") + + try: + result = await process_chunk_upload( + db=db, + session=session, + chunk_index=idx, + chunk_data=chunk_data, + chunk_sha256=sha + ) + return result + except ValueError as ex: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex)) + +@router.get("/{session_code}/status", response_model=UploadSessionStatusResponse) +async def get_session_status( + session_code: str, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """ + Returns the current status of an upload session, including the list of received + chunks and missing chunks for easy re-connection and resumption. + """ + res = await db.execute( + select(BackupSession).where( + BackupSession.session_code == session_code, + BackupSession.client_id == current_client.id + ) + ) + session = res.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found") + + return await get_session_status_info(db, session) + +@router.post("/{session_code}/complete", response_model=UploadSessionCompleteResponse) +async def complete_upload( + session_code: str, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """ + Triggers sequential file assembly and final SHA-256 integrity verification. + If integrity passes, the backup file is registered and retention policies are applied. + """ + res = await db.execute( + select(BackupSession).where( + BackupSession.session_code == session_code, + BackupSession.client_id == current_client.id + ) + ) + session = res.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found") + + try: + backup_file = await complete_session(db, session) + return { + "session_code": session.session_code, + "filename": backup_file.filename, + "relative_path": backup_file.relative_path, + "file_size": backup_file.file_size, + "sha256": backup_file.sha256, + "status": "SUCCESS", + "completed_at": backup_file.created_at + } + except Exception as ex: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Integrity check or assembly failed: {str(ex)}" + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..67d13f3 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +class Settings(BaseSettings): + PROJECT_NAME: str = "OnEver Drive" + VERSION: str = "1.0.0" + API_V1_PREFIX: str = "/api" + + # Environment + ENVIRONMENT: str = "development" + DEBUG: bool = True + + # Database: Default to SQLite for seamless local dev & test, configurable to PostgreSQL in production + DATABASE_URL: str = f"sqlite+aiosqlite:///{BASE_DIR}/onever_drive.db" + + # Storage settings + STORAGE_ROOT: str = str(BASE_DIR / "storage" / "backups") + STORAGE_TEMP_ROOT: str = str(BASE_DIR / "storage" / "temp") + + # Security + SECRET_KEY: str = "onever-drive-super-secret-key-change-in-production-2026" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # Upload & Transfer settings + DEFAULT_CHUNK_SIZE: int = 4 * 1024 * 1024 # 4 MB + MAX_CHUNK_SIZE: int = 16 * 1024 * 1024 # 16 MB + MIN_STABLE_TIME_SECONDS: int = 60 # 60s stability window for locked files + + # Retention Defaults + DEFAULT_RETENTION_DAILY: int = 7 + DEFAULT_RETENTION_WEEKLY: int = 4 + DEFAULT_RETENTION_MONTHLY: int = 12 + + model_config = {"env_file": ".env", "extra": "allow"} + +settings = Settings() + +# Ensure storage directories exist +os.makedirs(settings.STORAGE_ROOT, exist_ok=True) +os.makedirs(settings.STORAGE_TEMP_ROOT, exist_ok=True) diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..7105241 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,34 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base +from app.core.config import settings + +# Configure async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + future=True, + # SQLite-specific optimization if running SQLite + connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {} +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False +) + +Base = declarative_base() + +async def get_db(): + """FastAPI dependency for obtaining async database sessions.""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + +async def init_db(): + """Initializes database tables if they do not already exist.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..cee43ed --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,57 @@ +import secrets +import hashlib +from datetime import datetime, timedelta, timezone +from typing import Optional, Any +import jwt +import bcrypt +from app.core.config import settings + +def get_password_hash(password: str) -> str: + """Hashes a password with bcrypt.""" + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifies a plain password against a bcrypt hash.""" + try: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + except Exception: + return False + +def create_access_token(subject: Any, role: str = "ADMIN", expires_delta: Optional[timedelta] = None) -> str: + """Creates a JWT access token for authentication.""" + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = { + "exp": expire, + "sub": str(subject), + "role": role, + "iat": datetime.now(timezone.utc) + } + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> Optional[dict]: + """Decodes and validates a JWT token.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except Exception: + return None + +def generate_registration_code() -> str: + """Generates an agent registration code formatted as OED-XXXX-XXXX.""" + part1 = secrets.token_hex(2).upper() + part2 = secrets.token_hex(2).upper() + return f"OED-{part1}-{part2}" + +def generate_device_token() -> str: + """Generates a high-entropy secret token for an agent device.""" + return f"oed_sec_{secrets.token_urlsafe(32)}" + +def hash_token(token: str) -> str: + """Returns the SHA-256 hex digest of a token for secure database lookup.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..14553dd --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,88 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import select + +from app.core.config import settings +from app.core.database import init_db, AsyncSessionLocal +from app.core.security import get_password_hash +from app.models.models import User +from app.api.auth import router as auth_router +from app.api.clients import router as clients_router +from app.api.jobs import router as jobs_router +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.ws.manager import ws_manager + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Initialize database tables + await init_db() + + # Seed default administrator if not present + async with AsyncSessionLocal() as session: + result = await session.execute(select(User)) + admin = result.scalar_one_or_none() + if not admin: + admin_user = User( + email="admin@oneverdrive.local", + hashed_password=get_password_hash("Admin1234!"), + full_name="System Administrator", + role="ADMIN", + is_active=True + ) + session.add(admin_user) + await session.commit() + print(">> [OnEver Drive] Default admin created: admin@oneverdrive.local / Admin1234!") + + yield + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + description="Centralized Backup & Sync Platform for Windows on Proxmox VE", + lifespan=lifespan +) + +# CORS Middleware to allow Web UI connections +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register API Routers +app.include_router(auth_router, prefix=settings.API_V1_PREFIX) +app.include_router(clients_router, prefix=settings.API_V1_PREFIX) +app.include_router(jobs_router, prefix=settings.API_V1_PREFIX) +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.websocket("/ws/telemetry") +async def websocket_telemetry(websocket: WebSocket): + """WebSocket endpoint for real-time dashboard telemetry and live upload meters.""" + await ws_manager.connect(websocket) + try: + while True: + # Keep connection open and accept incoming ping/pong or messages + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + ws_manager.disconnect(websocket) + except Exception: + ws_manager.disconnect(websocket) + +@app.get("/health") +async def health(): + return { + "status": "healthy", + "service": settings.PROJECT_NAME, + "version": settings.VERSION + } diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..e6a23f1 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,174 @@ +from datetime import datetime, timezone +import uuid +from sqlalchemy import ( + Column, String, Integer, BigInteger, Boolean, DateTime, + ForeignKey, Text, Index +) +from sqlalchemy.orm import relationship +from app.core.database import Base + +def utc_now(): + return datetime.now(timezone.utc) + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=True) + role = Column(String(50), default="ADMIN", nullable=False) # ADMIN, OPERATOR, VIEWER + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + +class Client(Base): + __tablename__ = "clients" + + 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) + hostname = Column(String(255), nullable=True) + os_info = Column(String(255), nullable=True) + ip_address = Column(String(100), nullable=True) + agent_version = Column(String(50), default="1.0.0", nullable=False) + status = Column(String(50), default="OFFLINE", nullable=False) # ONLINE, OFFLINE, SYNCING, ERROR + storage_used_bytes = Column(BigInteger, default=0, nullable=False) + storage_quota_bytes = Column(BigInteger, default=100 * 1024 * 1024 * 1024, nullable=False) # 100 GB default + last_seen_at = Column(DateTime(timezone=True), nullable=True) + last_backup_at = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + + credentials = relationship("ClientCredential", back_populates="client", cascade="all, delete-orphan") + jobs = relationship("BackupJob", back_populates="client", cascade="all, delete-orphan") + backup_files = relationship("BackupFile", back_populates="client", cascade="all, delete-orphan") + backup_sessions = relationship("BackupSession", back_populates="client", cascade="all, delete-orphan") + +class ClientCredential(Base): + __tablename__ = "client_credentials" + + id = Column(Integer, primary_key=True, index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False) + device_id = Column(String(100), unique=True, index=True, nullable=False) # Unique UUID + token_hash = Column(String(255), unique=True, index=True, nullable=False) + name = Column(String(255), default="Primary Windows Agent", nullable=False) + is_revoked = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + last_used_at = Column(DateTime(timezone=True), nullable=True) + + client = relationship("Client", back_populates="credentials") + +class RegistrationCode(Base): + __tablename__ = "registration_codes" + + id = Column(Integer, primary_key=True, index=True) + 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) + expires_at = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + +class BackupJob(Base): + __tablename__ = "backup_jobs" + + id = Column(Integer, primary_key=True, index=True) + job_code = Column(String(50), unique=True, index=True, nullable=False) # e.g., JOB-001 + client_id = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False) + name = Column(String(255), nullable=False) + source_path = Column(String(1024), nullable=False) # e.g., C:\SQLBackups + file_patterns = Column(String(255), default="*.bak,*.mdf", nullable=False) + schedule_cron = Column(String(100), default="0 2 * * *", nullable=False) # default 02:00 AM daily + is_active = Column(Boolean, default=True, nullable=False) + + # Retention policies + keep_daily = Column(Integer, default=7, nullable=False) + keep_weekly = Column(Integer, default=4, nullable=False) + keep_monthly = Column(Integer, default=12, nullable=False) + min_stable_time_seconds = Column(Integer, default=60, nullable=False) + + status = Column(String(50), default="IDLE", nullable=False) # IDLE, RUNNING, ERROR, SUCCESS + last_run_at = Column(DateTime(timezone=True), nullable=True) + next_run_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + + client = relationship("Client", back_populates="jobs") + backup_files = relationship("BackupFile", back_populates="job", cascade="all, delete-orphan") + backup_sessions = relationship("BackupSession", back_populates="job", cascade="all, delete-orphan") + +class BackupSession(Base): + __tablename__ = "backup_sessions" + + id = Column(Integer, primary_key=True, index=True) + session_code = Column(String(100), unique=True, index=True, default=lambda: str(uuid.uuid4()), nullable=False) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False) + job_id = Column(Integer, ForeignKey("backup_jobs.id", ondelete="CASCADE"), nullable=True) + + filename = Column(String(512), nullable=False) + file_size = Column(BigInteger, nullable=False) + chunk_size = Column(Integer, default=4 * 1024 * 1024, nullable=False) + total_chunks = Column(Integer, nullable=False) + received_chunks_count = Column(Integer, default=0, nullable=False) + sha256_full = Column(String(64), nullable=False) + + status = Column(String(50), default="PENDING", nullable=False) # PENDING, UPLOADING, ASSEMBLING, VERIFYING, SUCCESS, FAILED, CANCELLED + error_message = Column(Text, nullable=True) + started_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + completed_at = Column(DateTime(timezone=True), nullable=True) + + client = relationship("Client", back_populates="backup_sessions") + job = relationship("BackupJob", back_populates="backup_sessions") + chunks = relationship("BackupChunk", back_populates="session", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_session_client_status", "client_id", "status"), + ) + +class BackupChunk(Base): + __tablename__ = "backup_chunks" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("backup_sessions.id", ondelete="CASCADE"), nullable=False) + chunk_index = Column(Integer, nullable=False) + chunk_size = Column(Integer, nullable=False) + sha256 = Column(String(64), nullable=False) + is_received = Column(Boolean, default=False, nullable=False) + received_at = Column(DateTime(timezone=True), nullable=True) + + session = relationship("BackupSession", back_populates="chunks") + + __table_args__ = ( + Index("idx_chunk_session_idx", "session_id", "chunk_index", unique=True), + ) + +class BackupFile(Base): + __tablename__ = "backup_files" + + id = Column(Integer, primary_key=True, index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False) + job_id = Column(Integer, ForeignKey("backup_jobs.id", ondelete="CASCADE"), nullable=True) + session_id = Column(Integer, ForeignKey("backup_sessions.id", ondelete="SET NULL"), nullable=True) + + filename = Column(String(512), nullable=False) + relative_path = Column(String(1024), nullable=False) + file_size = Column(BigInteger, nullable=False) + sha256 = Column(String(64), nullable=False) + retention_tag = Column(String(50), default="DAILY", nullable=False) # DAILY, WEEKLY, MONTHLY, MANUAL + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) + + client = relationship("Client", back_populates="backup_files") + job = relationship("BackupJob", back_populates="backup_files") + +class EventLog(Base): + __tablename__ = "event_logs" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True) + event_type = Column(String(100), nullable=False, index=True) # LOGIN, CLIENT_REGISTERED, BACKUP_STARTED, BACKUP_COMPLETED, BACKUP_FAILED, etc. + severity = Column(String(50), default="INFO", nullable=False) # INFO, WARNING, ERROR, CRITICAL + client_id = Column(Integer, nullable=True, index=True) + job_id = Column(Integer, nullable=True, index=True) + user_email = Column(String(255), nullable=True) + ip_address = Column(String(100), nullable=True) + message = Column(Text, nullable=False) + details_json = Column(Text, nullable=True) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..60563ca --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -0,0 +1,207 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Any, Dict +from datetime import datetime + +# --- Auth Schemas --- +class LoginRequest(BaseModel): + email: str + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: Dict[str, Any] + +class UserResponse(BaseModel): + id: int + email: str + full_name: Optional[str] + role: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +# --- Client Schemas --- +class RegistrationCodeCreate(BaseModel): + client_name_hint: Optional[str] = None + expires_in_hours: int = 48 + +class RegistrationCodeResponse(BaseModel): + code: str + expires_at: datetime + client_name_hint: Optional[str] + +class ClientRegisterRequest(BaseModel): + registration_code: str + name: str + hostname: str + os_info: Optional[str] = None + agent_version: str = "1.0.0" + +class ClientRegisterResponse(BaseModel): + client_code: str + device_id: str + device_token: str + name: str + server_time: datetime + +class ClientHeartbeatRequest(BaseModel): + status: str = "ONLINE" # ONLINE, OFFLINE, SYNCING, ERROR + agent_version: Optional[str] = None + ip_address: Optional[str] = None + +class ClientResponse(BaseModel): + id: int + client_code: str + name: str + hostname: Optional[str] + os_info: Optional[str] + ip_address: Optional[str] + agent_version: str + status: str + storage_used_bytes: int + storage_quota_bytes: int + last_seen_at: Optional[datetime] + last_backup_at: Optional[datetime] + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +# --- Backup Job Schemas --- +class JobCreate(BaseModel): + client_id: int + name: str + source_path: str + file_patterns: str = "*.bak,*.mdf" + schedule_cron: str = "0 2 * * *" + keep_daily: int = 7 + keep_weekly: int = 4 + keep_monthly: int = 12 + min_stable_time_seconds: int = 60 + +class JobUpdate(BaseModel): + name: Optional[str] = None + source_path: Optional[str] = None + file_patterns: Optional[str] = None + schedule_cron: Optional[str] = None + is_active: Optional[bool] = None + keep_daily: Optional[int] = None + keep_weekly: Optional[int] = None + keep_monthly: Optional[int] = None + min_stable_time_seconds: Optional[int] = None + +class JobResponse(BaseModel): + id: int + job_code: str + client_id: int + name: str + source_path: str + file_patterns: str + schedule_cron: str + is_active: bool + keep_daily: int + keep_weekly: int + keep_monthly: int + min_stable_time_seconds: int + status: str + last_run_at: Optional[datetime] + next_run_at: Optional[datetime] + created_at: datetime + + model_config = {"from_attributes": True} + +# --- Upload Session & Chunk Schemas --- +class UploadSessionInitRequest(BaseModel): + filename: str + file_size: int + sha256: str + chunk_size: int = 4 * 1024 * 1024 + job_id: Optional[int] = None + +class UploadSessionInitResponse(BaseModel): + session_code: str + filename: str + file_size: int + chunk_size: int + total_chunks: int + received_chunks: List[int] + status: str + +class UploadSessionStatusResponse(BaseModel): + session_code: str + filename: str + file_size: int + chunk_size: int + total_chunks: int + received_chunks: List[int] + missing_chunks: List[int] + status: str + progress_percent: float + +class ChunkUploadResponse(BaseModel): + chunk_index: int + is_received: bool + total_received: int + total_chunks: int + progress_percent: float + +class UploadSessionCompleteResponse(BaseModel): + session_code: str + filename: str + relative_path: str + file_size: int + sha256: str + status: str + completed_at: datetime + +# --- Backup File Schemas --- +class BackupFileResponse(BaseModel): + id: int + client_id: int + job_id: Optional[int] + session_id: Optional[int] + filename: str + relative_path: str + file_size: int + sha256: str + retention_tag: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +# --- Event Log Schemas --- +class EventLogResponse(BaseModel): + id: int + timestamp: datetime + event_type: str + severity: str + client_id: Optional[int] + job_id: Optional[int] + user_email: Optional[str] + ip_address: Optional[str] + message: str + details_json: Optional[str] + + model_config = {"from_attributes": True} + +# --- Dashboard & Storage Stats --- +class StorageStatsResponse(BaseModel): + total_bytes: int + used_bytes: int + free_bytes: int + usage_percent: float + storage_root: str + +class DashboardStatsResponse(BaseModel): + total_clients: int + online_clients: int + offline_clients: int + total_jobs: int + backups_today_count: int + backups_today_success: int + backups_today_failed: int + active_uploads_count: int + storage: StorageStatsResponse diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py new file mode 100644 index 0000000..17a32ce --- /dev/null +++ b/backend/app/services/event_service.py @@ -0,0 +1,48 @@ +import json +from datetime import datetime, timezone +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import EventLog +from app.ws.manager import ws_manager + +async def log_event( + db: AsyncSession, + event_type: str, + message: str, + severity: str = "INFO", + client_id: Optional[int] = None, + job_id: Optional[int] = None, + user_email: Optional[str] = None, + ip_address: Optional[str] = None, + details: Optional[Dict[str, Any]] = None +) -> EventLog: + """Records an audit event in the database and broadcasts it over WebSockets.""" + details_str = json.dumps(details, default=str) if details else None + + event = EventLog( + timestamp=datetime.now(timezone.utc), + event_type=event_type, + severity=severity, + client_id=client_id, + job_id=job_id, + user_email=user_email, + ip_address=ip_address, + message=message, + details_json=details_str + ) + db.add(event) + await db.commit() + await db.refresh(event) + + # Broadcast real-time event to all Web UI connections + await ws_manager.broadcast("EVENT_LOG", { + "id": event.id, + "timestamp": event.timestamp.isoformat(), + "event_type": event.event_type, + "severity": event.severity, + "client_id": event.client_id, + "job_id": event.job_id, + "message": event.message + }) + + return event diff --git a/backend/app/services/retention_service.py b/backend/app/services/retention_service.py new file mode 100644 index 0000000..a9ddd14 --- /dev/null +++ b/backend/app/services/retention_service.py @@ -0,0 +1,110 @@ +from datetime import datetime, timedelta, timezone +from typing import List, Set +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.models import BackupJob, BackupFile, Client +from app.storage.local import storage_provider +from app.services.event_service import log_event + +async def apply_retention_policy(db: AsyncSession, job_id: int) -> int: + """ + Applies retention rules (daily, weekly, monthly) for a specific backup job. + Safely deletes obsolete backup files from storage and database. + Returns the number of pruned backup files. + """ + # Fetch job details + result = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) + job = result.scalar_one_or_none() + if not job: + return 0 + + # Fetch all active backup files for this job ordered by creation date descending + result = await db.execute( + select(BackupFile) + .where(BackupFile.job_id == job_id, BackupFile.is_active == True) + .order_by(BackupFile.created_at.desc()) + ) + backup_files: List[BackupFile] = result.scalars().all() + + if not backup_files: + return 0 + + now = datetime.now(timezone.utc) + protected_file_ids: Set[int] = set() + + # Always keep the most recent backup regardless of age to avoid empty repo + protected_file_ids.add(backup_files[0].id) + + # 1. Daily retention: Keep 1 backup per calendar day for the last `job.keep_daily` days + seen_days = set() + for bf in backup_files: + day_key = bf.created_at.strftime("%Y-%m-%d") + age_days = (now - bf.created_at).total_seconds() / 86400.0 + if age_days <= job.keep_daily: + if day_key not in seen_days: + seen_days.add(day_key) + protected_file_ids.add(bf.id) + + # 2. Weekly retention: Keep 1 backup per calendar week for the last `job.keep_weekly` weeks + seen_weeks = set() + for bf in backup_files: + week_key = f"{bf.created_at.year}-W{bf.created_at.isocalendar()[1]:02d}" + age_weeks = (now - bf.created_at).total_seconds() / (86400.0 * 7) + if age_weeks <= job.keep_weekly: + if week_key not in seen_weeks: + seen_weeks.add(week_key) + protected_file_ids.add(bf.id) + + # 3. Monthly retention: Keep 1 backup per calendar month for the last `job.keep_monthly` months + seen_months = set() + for bf in backup_files: + month_key = bf.created_at.strftime("%Y-%m") + # Approximate age in months (30 days per month) + age_months = (now - bf.created_at).total_seconds() / (86400.0 * 30.4375) + if age_months <= job.keep_monthly: + if month_key not in seen_months: + seen_months.add(month_key) + protected_file_ids.add(bf.id) + + # Identify files to delete + files_to_delete = [bf for bf in backup_files if bf.id not in protected_file_ids] + pruned_count = 0 + reclaimed_bytes = 0 + + for bf in files_to_delete: + try: + # Delete from physical storage + await storage_provider.delete_backup_file(bf.relative_path) + bf.is_active = False + pruned_count += 1 + reclaimed_bytes += bf.file_size + except Exception as ex: + # Log failure but continue processing other files + await log_event( + db=db, + event_type="RETENTION_ERROR", + message=f"Failed to delete pruned backup file {bf.filename}: {str(ex)}", + severity="WARNING", + client_id=job.client_id, + job_id=job.id + ) + + if pruned_count > 0: + # Update client storage used counter + client_res = await db.execute(select(Client).where(Client.id == job.client_id)) + client = client_res.scalar_one_or_none() + if client: + client.storage_used_bytes = max(0, client.storage_used_bytes - reclaimed_bytes) + + await db.commit() + + await log_event( + db=db, + event_type="RETENTION_APPLIED", + message=f"Retention policy applied for job '{job.name}': {pruned_count} obsolete backups removed, {reclaimed_bytes / (1024*1024):.2f} MB reclaimed.", + severity="INFO", + client_id=job.client_id, + job_id=job.id + ) + + return pruned_count diff --git a/backend/app/services/upload_service.py b/backend/app/services/upload_service.py new file mode 100644 index 0000000..501f745 --- /dev/null +++ b/backend/app/services/upload_service.py @@ -0,0 +1,277 @@ +import math +from datetime import datetime, timezone +from typing import Tuple, List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.models import Client, BackupJob, BackupSession, BackupChunk, BackupFile +from app.storage.local import storage_provider +from app.services.event_service import log_event +from app.services.retention_service import apply_retention_policy +from app.ws.manager import ws_manager + +async def create_or_resume_session( + db: AsyncSession, + client: Client, + filename: str, + file_size: int, + sha256_full: str, + chunk_size: int = 4 * 1024 * 1024, + job_id: Optional[int] = None +) -> Tuple[BackupSession, List[int]]: + """ + Initializes a new upload session or resumes an existing incomplete session + for the specified file and hash. + """ + total_chunks = max(1, math.ceil(file_size / chunk_size)) + + # Check for existing incomplete session for this client and file hash + query = ( + select(BackupSession) + .where( + BackupSession.client_id == client.id, + BackupSession.sha256_full == sha256_full, + BackupSession.file_size == file_size, + BackupSession.status.in_(["PENDING", "UPLOADING"]) + ) + ) + result = await db.execute(query) + session = result.scalar_one_or_none() + + if session: + # Resume existing session + received_chunks = await storage_provider.get_received_chunks(session.session_code) + session.received_chunks_count = len(received_chunks) + await db.commit() + await db.refresh(session) + return session, received_chunks + + # Create new upload session + session = BackupSession( + client_id=client.id, + job_id=job_id, + filename=filename, + file_size=file_size, + chunk_size=chunk_size, + total_chunks=total_chunks, + received_chunks_count=0, + sha256_full=sha256_full, + status="UPLOADING", + started_at=datetime.now(timezone.utc) + ) + db.add(session) + await db.commit() + await db.refresh(session) + + # Initialize temporary storage + await storage_provider.init_session_storage(session.session_code) + + await log_event( + db=db, + event_type="BACKUP_STARTED", + message=f"Upload session initiated for '{filename}' ({file_size / (1024*1024):.2f} MB, {total_chunks} chunks).", + severity="INFO", + client_id=client.id, + job_id=job_id, + details={"session_code": session.session_code, "total_chunks": total_chunks} + ) + + return session, [] + +async def process_chunk_upload( + db: AsyncSession, + session: BackupSession, + chunk_index: int, + chunk_data: bytes, + chunk_sha256: Optional[str] = None +) -> Dict[str, Any]: + """ + Saves a chunk to temporary storage, registers chunk in database, + and broadcasts live progress telemetry. + """ + if session.status not in ["PENDING", "UPLOADING"]: + raise ValueError(f"Cannot upload chunk: session is currently in state {session.status}") + + if chunk_index < 0 or chunk_index >= session.total_chunks: + raise ValueError(f"Invalid chunk_index {chunk_index}. Session total chunks: {session.total_chunks}") + + # Save to storage (performs chunk SHA-256 verification if provided) + await storage_provider.save_chunk( + session_code=session.session_code, + chunk_index=chunk_index, + chunk_data=chunk_data, + expected_sha256=chunk_sha256 + ) + + # Record in database + result = await db.execute( + select(BackupChunk).where( + BackupChunk.session_id == session.id, + BackupChunk.chunk_index == chunk_index + ) + ) + chunk_rec = result.scalar_one_or_none() + + if not chunk_rec: + chunk_rec = BackupChunk( + session_id=session.id, + chunk_index=chunk_index, + chunk_size=len(chunk_data), + sha256=chunk_sha256 or "", + is_received=True, + received_at=datetime.now(timezone.utc) + ) + db.add(chunk_rec) + else: + chunk_rec.is_received = True + chunk_rec.received_at = datetime.now(timezone.utc) + + # Count received chunks + received_list = await storage_provider.get_received_chunks(session.session_code) + session.received_chunks_count = len(received_list) + await db.commit() + + progress_pct = round((session.received_chunks_count / session.total_chunks) * 100, 2) + + # Broadcast live telemetry over WebSocket + await ws_manager.broadcast("UPLOAD_PROGRESS", { + "session_code": session.session_code, + "filename": session.filename, + "client_id": session.client_id, + "chunk_index": chunk_index, + "received_chunks": session.received_chunks_count, + "total_chunks": session.total_chunks, + "progress_percent": progress_pct + }) + + return { + "chunk_index": chunk_index, + "is_received": True, + "total_received": session.received_chunks_count, + "total_chunks": session.total_chunks, + "progress_percent": progress_pct + } + +async def get_session_status_info( + db: AsyncSession, + session: BackupSession +) -> Dict[str, Any]: + """Returns detailed session status and lists of received / missing chunks.""" + received = await storage_provider.get_received_chunks(session.session_code) + received_set = set(received) + missing = [i for i in range(session.total_chunks) if i not in received_set] + progress_pct = round((len(received) / session.total_chunks) * 100, 2) + + return { + "session_code": session.session_code, + "filename": session.filename, + "file_size": session.file_size, + "chunk_size": session.chunk_size, + "total_chunks": session.total_chunks, + "received_chunks": received, + "missing_chunks": missing, + "status": session.status, + "progress_percent": progress_pct + } + +async def complete_session( + db: AsyncSession, + session: BackupSession +) -> BackupFile: + """ + Assembles chunks into final storage, verifies SHA-256 integrity, + updates client stats, applies retention policy, and logs completion. + """ + # Fetch client and job codes for directory naming + client_res = await db.execute(select(Client).where(Client.id == session.client_id)) + client = client_res.scalar_one_or_none() + if not client: + raise ValueError(f"Client {session.client_id} not found") + + job_code = "DEFAULT" + 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_code = job.job_code + + session.status = "ASSEMBLING" + await db.commit() + + try: + # Assemble and verify streaming SHA-256 + rel_path, final_sha256, total_bytes = await storage_provider.assemble_file( + session_code=session.session_code, + client_code=client.client_code, + job_code=job_code, + filename=session.filename, + total_chunks=session.total_chunks, + expected_sha256=session.sha256_full + ) + + session.status = "SUCCESS" + session.completed_at = datetime.now(timezone.utc) + + # Create BackupFile record + backup_file = BackupFile( + client_id=client.id, + job_id=session.job_id, + session_id=session.id, + filename=session.filename, + relative_path=rel_path, + file_size=total_bytes, + sha256=final_sha256, + retention_tag="DAILY", + is_active=True, + created_at=datetime.now(timezone.utc) + ) + db.add(backup_file) + + # Update client storage and last backup timestamp + client.storage_used_bytes += total_bytes + client.last_backup_at = datetime.now(timezone.utc) + + await db.commit() + await db.refresh(backup_file) + + # Log completion event + await log_event( + db=db, + event_type="BACKUP_COMPLETED", + message=f"Backup successfully verified & stored: '{session.filename}' ({total_bytes / (1024*1024):.2f} MB). SHA-256: {final_sha256[:16]}...", + severity="INFO", + client_id=client.id, + job_id=session.job_id, + details={"sha256": final_sha256, "file_size": total_bytes, "path": rel_path} + ) + + # Apply retention policy if associated with a job + if session.job_id: + await apply_retention_policy(db, session.job_id) + + # Broadcast completion + await ws_manager.broadcast("UPLOAD_COMPLETED", { + "session_code": session.session_code, + "filename": session.filename, + "client_id": client.id, + "file_size": total_bytes, + "sha256": final_sha256, + "status": "SUCCESS" + }) + + return backup_file + + except Exception as ex: + session.status = "FAILED" + session.error_message = str(ex) + await db.commit() + + await log_event( + db=db, + event_type="BACKUP_FAILED", + message=f"Backup assembly/verification failed for '{session.filename}': {str(ex)}", + severity="ERROR", + client_id=client.id, + job_id=session.job_id, + details={"error": str(ex)} + ) + raise ex diff --git a/backend/app/storage/base.py b/backend/app/storage/base.py new file mode 100644 index 0000000..23a093b --- /dev/null +++ b/backend/app/storage/base.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod +from typing import List, Tuple, Dict, Any, Optional + +class BaseStorageProvider(ABC): + """Abstract interface for OnEver Drive storage backends (Local FS, S3, MinIO, etc.).""" + + @abstractmethod + async def init_session_storage(self, session_code: str) -> None: + """Prepares temporary storage directory for an incoming upload session.""" + pass + + @abstractmethod + async def save_chunk( + self, + session_code: str, + chunk_index: int, + chunk_data: bytes, + expected_sha256: Optional[str] = None + ) -> bool: + """Saves a single chunk, validates its hash, and returns True on success.""" + pass + + @abstractmethod + async def get_received_chunks(self, session_code: str) -> List[int]: + """Returns the list of indices of all successfully stored chunks for a session.""" + pass + + @abstractmethod + async def assemble_file( + self, + session_code: str, + client_code: str, + job_code: str, + filename: str, + total_chunks: int, + expected_sha256: str + ) -> Tuple[str, str, int]: + """ + Assembles all stored chunks in order into the final destination file, + calculates full streaming SHA-256 hash, and verifies integrity. + Returns: (relative_storage_path, actual_sha256, file_size_bytes). + Raises ValueError if integrity check fails or chunks are missing. + """ + pass + + @abstractmethod + async def delete_session_temp(self, session_code: str) -> None: + """Cleans up temporary chunks after assembly or cancellation.""" + pass + + @abstractmethod + async def delete_backup_file(self, relative_path: str) -> bool: + """Deletes a backup file from storage.""" + pass + + @abstractmethod + async def get_file_path(self, relative_path: str) -> str: + """Resolves absolute path for reading/restoring.""" + pass + + @abstractmethod + async def get_storage_stats(self) -> Dict[str, Any]: + """Returns storage capacity stats: {total_bytes, used_bytes, free_bytes, usage_percent}.""" + pass diff --git a/backend/app/storage/local.py b/backend/app/storage/local.py new file mode 100644 index 0000000..bdb7f3d --- /dev/null +++ b/backend/app/storage/local.py @@ -0,0 +1,168 @@ +import os +import shutil +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional +import aiofiles + +from app.core.config import settings +from app.storage.base import BaseStorageProvider + +class LocalStorageProvider(BaseStorageProvider): + """Local filesystem storage provider designed for Proxmox VE dedicated mount volumes.""" + + def __init__(self, root_dir: Optional[str] = None, temp_dir: Optional[str] = None): + self.root_dir = Path(root_dir or settings.STORAGE_ROOT).resolve() + self.temp_dir = Path(temp_dir or settings.STORAGE_TEMP_ROOT).resolve() + self.root_dir.mkdir(parents=True, exist_ok=True) + self.temp_dir.mkdir(parents=True, exist_ok=True) + + def _get_session_temp_dir(self, session_code: str) -> Path: + return self.temp_dir / session_code + + async def init_session_storage(self, session_code: str) -> None: + session_path = self._get_session_temp_dir(session_code) + session_path.mkdir(parents=True, exist_ok=True) + + async def save_chunk( + self, + session_code: str, + chunk_index: int, + chunk_data: bytes, + expected_sha256: Optional[str] = None + ) -> bool: + # Validate individual chunk hash if provided + if expected_sha256: + actual_chunk_hash = hashlib.sha256(chunk_data).hexdigest() + if actual_chunk_hash.lower() != expected_sha256.lower(): + raise ValueError( + f"Chunk {chunk_index} checksum mismatch: expected {expected_sha256}, got {actual_chunk_hash}" + ) + + session_path = self._get_session_temp_dir(session_code) + session_path.mkdir(parents=True, exist_ok=True) + chunk_file = session_path / f"{chunk_index:08d}.chunk" + + async with aiofiles.open(chunk_file, "wb") as f: + await f.write(chunk_data) + + return True + + async def get_received_chunks(self, session_code: str) -> List[int]: + session_path = self._get_session_temp_dir(session_code) + if not session_path.exists(): + return [] + + chunks = [] + for file in session_path.glob("*.chunk"): + try: + index = int(file.stem) + chunks.append(index) + except ValueError: + continue + chunks.sort() + return chunks + + async def assemble_file( + self, + session_code: str, + client_code: str, + job_code: str, + filename: str, + total_chunks: int, + expected_sha256: str + ) -> Tuple[str, str, int]: + session_path = self._get_session_temp_dir(session_code) + if not session_path.exists(): + raise FileNotFoundError(f"Upload session temporary directory {session_code} does not exist") + + # Verify all chunks are present + received_chunks = set(await self.get_received_chunks(session_code)) + missing_chunks = [i for i in range(total_chunks) if i not in received_chunks] + if missing_chunks: + raise ValueError(f"Cannot assemble file. Missing {len(missing_chunks)} chunks: {missing_chunks[:10]}...") + + # Prepare client isolated destination directory + dest_dir = self.root_dir / "clients" / client_code / (job_code or "DEFAULT") + dest_dir.mkdir(parents=True, exist_ok=True) + + timestamp_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + safe_filename = Path(filename).name + target_filename = f"{timestamp_str}_{safe_filename}" + target_path = dest_dir / target_filename + relative_path = str(target_path.relative_to(self.root_dir)).replace("\\", "/") + + hasher = hashlib.sha256() + total_bytes = 0 + + # Stream and concatenate all chunks in sequential order + async with aiofiles.open(target_path, "wb") as out_file: + for idx in range(total_chunks): + chunk_file = session_path / f"{idx:08d}.chunk" + if not chunk_file.exists(): + # Clean up target on failure + if target_path.exists(): + target_path.unlink() + raise FileNotFoundError(f"Missing chunk file {chunk_file}") + + async with aiofiles.open(chunk_file, "rb") as in_chunk: + while True: + buffer = await in_chunk.read(1024 * 1024) # 1MB buffer + if not buffer: + break + hasher.update(buffer) + total_bytes += len(buffer) + await out_file.write(buffer) + + final_sha256 = hasher.hexdigest() + + # Strict integrity check against client's pre-calculated full SHA-256 + if final_sha256.lower() != expected_sha256.lower(): + if target_path.exists(): + target_path.unlink() + raise ValueError( + f"Full file integrity check failed! Expected SHA-256: {expected_sha256}, Actual: {final_sha256}" + ) + + # Cleanup temporary chunks upon confirmed assembly & integrity verification + await self.delete_session_temp(session_code) + + return relative_path, final_sha256, total_bytes + + async def delete_session_temp(self, session_code: str) -> None: + session_path = self._get_session_temp_dir(session_code) + if session_path.exists(): + shutil.rmtree(session_path, ignore_errors=True) + + async def delete_backup_file(self, relative_path: str) -> bool: + full_path = (self.root_dir / relative_path).resolve() + # Security guard: prevent path traversal outside root_dir + if not str(full_path).startswith(str(self.root_dir)): + raise PermissionError("Attempted path traversal outside storage root") + if full_path.exists() and full_path.is_file(): + full_path.unlink() + return True + return False + + async def get_file_path(self, relative_path: str) -> str: + full_path = (self.root_dir / relative_path).resolve() + if not str(full_path).startswith(str(self.root_dir)): + raise PermissionError("Attempted path traversal outside storage root") + if not full_path.exists(): + raise FileNotFoundError(f"Backup file {relative_path} not found") + return str(full_path) + + async def get_storage_stats(self) -> Dict[str, Any]: + total, used, free = shutil.disk_usage(self.root_dir) + usage_pct = round((used / total) * 100, 2) if total > 0 else 0 + return { + "total_bytes": total, + "used_bytes": used, + "free_bytes": free, + "usage_percent": usage_pct, + "storage_root": str(self.root_dir) + } + +# Global singleton storage provider +storage_provider = LocalStorageProvider() diff --git a/backend/app/ws/manager.py b/backend/app/ws/manager.py new file mode 100644 index 0000000..5d72987 --- /dev/null +++ b/backend/app/ws/manager.py @@ -0,0 +1,36 @@ +import json +from typing import List, Dict, Any +from fastapi import WebSocket + +class WebSocketManager: + """Manages active WebSocket connections for live telemetry and dashboard updates.""" + + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast(self, message_type: str, data: Dict[str, Any]): + """Broadcasts a structured JSON event to all connected dashboard clients.""" + payload = { + "type": message_type, + "data": data + } + message_str = json.dumps(payload, default=str) + dead_connections = [] + for connection in self.active_connections: + try: + await connection.send_text(message_str) + except Exception: + dead_connections.append(connection) + + for dead in dead_connections: + self.disconnect(dead) + +ws_manager = WebSocketManager() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..428accf --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.28.0 +pydantic>=2.6.0 +pydantic-settings>=2.2.0 +sqlalchemy>=2.0.28 +aiosqlite>=0.20.0 +asyncpg>=0.29.0 +psycopg2-binary>=2.9.9 +pyjwt>=2.8.0 +bcrypt>=4.1.2 +python-multipart>=0.0.9 +aiofiles>=23.2.1 +httpx>=0.27.0 +pytest>=8.1.0 +pytest-asyncio>=0.23.5 +websockets>=12.0 diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..95a3340 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,127 @@ +-- ============================================================================== +-- OnEver Drive — PostgreSQL Production Schema +-- ============================================================================== + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + hashed_password VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + role VARCHAR(50) DEFAULT 'ADMIN' NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + client_code VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + hostname VARCHAR(255), + os_info VARCHAR(255), + ip_address VARCHAR(100), + agent_version VARCHAR(50) DEFAULT '1.0.0' NOT NULL, + status VARCHAR(50) DEFAULT 'OFFLINE' NOT NULL, + storage_used_bytes BIGINT DEFAULT 0 NOT NULL, + storage_quota_bytes BIGINT DEFAULT 107374182400 NOT NULL, + last_seen_at TIMESTAMP WITH TIME ZONE, + last_backup_at TIMESTAMP WITH TIME ZONE, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS client_credentials ( + id SERIAL PRIMARY KEY, + client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE NOT NULL, + device_id VARCHAR(100) UNIQUE NOT NULL, + token_hash VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) DEFAULT 'Primary Windows Agent' NOT NULL, + is_revoked BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_used_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE IF NOT EXISTS registration_codes ( + id SERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + is_used BOOLEAN DEFAULT FALSE NOT NULL, + client_name_hint VARCHAR(255), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS backup_jobs ( + id SERIAL PRIMARY KEY, + job_code VARCHAR(50) UNIQUE NOT NULL, + client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE NOT NULL, + name VARCHAR(255) NOT NULL, + source_path VARCHAR(1024) NOT NULL, + file_patterns VARCHAR(255) DEFAULT '*.bak,*.mdf' NOT NULL, + schedule_cron VARCHAR(100) DEFAULT '0 2 * * *' NOT NULL, + keep_daily INTEGER DEFAULT 7 NOT NULL, + keep_weekly INTEGER DEFAULT 4 NOT NULL, + keep_monthly INTEGER DEFAULT 12 NOT NULL, + min_stable_time_seconds INTEGER DEFAULT 60 NOT NULL, + status VARCHAR(50) DEFAULT 'IDLE' NOT NULL, + last_run_at TIMESTAMP WITH TIME ZONE, + next_run_at TIMESTAMP WITH TIME ZONE, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS backup_sessions ( + id SERIAL PRIMARY KEY, + session_code VARCHAR(100) UNIQUE NOT NULL, + client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE NOT NULL, + job_id INTEGER REFERENCES backup_jobs(id) ON DELETE CASCADE, + filename VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL, + chunk_size INTEGER DEFAULT 4194304 NOT NULL, + total_chunks INTEGER NOT NULL, + received_chunks_count INTEGER DEFAULT 0 NOT NULL, + sha256_full VARCHAR(64) NOT NULL, + status VARCHAR(50) DEFAULT 'PENDING' NOT NULL, + error_message TEXT, + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE IF NOT EXISTS backup_chunks ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES backup_sessions(id) ON DELETE CASCADE NOT NULL, + chunk_index INTEGER NOT NULL, + chunk_size INTEGER NOT NULL, + sha256 VARCHAR(64) NOT NULL, + is_received BOOLEAN DEFAULT FALSE NOT NULL, + received_at TIMESTAMP WITH TIME ZONE, + UNIQUE(session_id, chunk_index) +); + +CREATE TABLE IF NOT EXISTS backup_files ( + id SERIAL PRIMARY KEY, + client_id INTEGER REFERENCES clients(id) ON DELETE CASCADE NOT NULL, + job_id INTEGER REFERENCES backup_jobs(id) ON DELETE CASCADE, + session_id INTEGER REFERENCES backup_sessions(id) ON DELETE SET NULL, + filename VARCHAR(512) NOT NULL, + relative_path VARCHAR(1024) NOT NULL, + file_size BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + retention_tag VARCHAR(50) DEFAULT 'DAILY' NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS event_logs ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + event_type VARCHAR(100) NOT NULL, + severity VARCHAR(50) DEFAULT 'INFO' NOT NULL, + client_id INTEGER, + job_id INTEGER, + user_email VARCHAR(255), + ip_address VARCHAR(100), + message TEXT NOT NULL, + details_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_client_status ON backup_sessions(client_id, status); +CREATE INDEX IF NOT EXISTS idx_event_logs_timestamp ON event_logs(timestamp); diff --git a/deployment/nginx/nginx.conf b/deployment/nginx/nginx.conf new file mode 100644 index 0000000..a82d1ab --- /dev/null +++ b/deployment/nginx/nginx.conf @@ -0,0 +1,56 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + # Allow streaming chunk uploads without buffer size limits + client_max_body_size 50M; + + upstream backend_upstream { + server backend:8000; + } + + server { + listen 80; + server_name _; + + # Frontend SPA Static Files + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + # API Reverse Proxy + location /api/ { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Direct chunk upload streaming + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 300s; + } + + # WebSocket Reverse Proxy + location /ws/ { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + } +} diff --git a/deployment/proxmox/01-create-lxc-pve-host.sh b/deployment/proxmox/01-create-lxc-pve-host.sh new file mode 100644 index 0000000..fd08879 --- /dev/null +++ b/deployment/proxmox/01-create-lxc-pve-host.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — Proxmox VE Host Provisioning Script +# Executed directly on the Proxmox VE Host Shell (pve) +# Creates native Debian 12 / 13 LXC Container and mounts persistent storage +# ============================================================================== + +set -e + +# Configuration variables +CT_ID=100 +HOSTNAME="onever-backend" +MEMORY=2048 +SWAP=1024 +CORES=2 +DISK_SIZE="20G" +STORAGE_POOL="local-lvm" +HOST_BACKUP_STORAGE="/mnt/pve/backup-pool" # Adjust to your Proxmox ZFS/LVM path +BRIDGE="vmbr0" +IP_CONFIG="dhcp" # Or: "192.168.1.100/24,gw=192.168.1.1" + +echo "========================================================================" +echo " ONEVER DRIVE — PROXMOX VE LXC CREATION SCRIPT " +echo "========================================================================" + +# 1. Check Proxmox environment +if ! command -v pct &> /dev/null; then + echo "[!] Error: 'pct' command not found. This script must be executed on a Proxmox VE node." + exit 1 +fi + +# 2. Check if CT ID already exists +if pct status "$CT_ID" &> /dev/null; then + echo "[!] Error: Container ID $CT_ID already exists. Please choose a different CT_ID." + exit 1 +fi + +# 3. Locate or download Debian 12 / 13 standard template +echo "[*] Searching for Debian 12 / 13 template..." +pveam update +TEMPLATE=$(pveam available | grep -E "debian-(12|13)-standard" | tail -n 1 | awk '{print $2}') + +if [ -z "$TEMPLATE" ]; then + echo "[!] Could not automatically find Debian template. Using default debian-12-standard." + TEMPLATE="debian-12-standard_12.2-1_amd64.tar.zst" +fi + +if ! pveam list local | grep -q "$TEMPLATE"; then + echo "[*] Downloading template: $TEMPLATE..." + pveam download local "$TEMPLATE" +fi + +TEMPLATE_PATH="local:vztmpl/$TEMPLATE" + +# 4. Create persistent host directories if not present +echo "[*] Preparing host persistent backup storage at: $HOST_BACKUP_STORAGE..." +mkdir -p "$HOST_BACKUP_STORAGE/backups" +mkdir -p "$HOST_BACKUP_STORAGE/temp" +chmod 775 "$HOST_BACKUP_STORAGE/backups" +chmod 777 "$HOST_BACKUP_STORAGE/temp" + +# 5. Create native LXC Container +echo "[*] Creating LXC container CT $CT_ID ($HOSTNAME)..." +pct create "$CT_ID" "$TEMPLATE_PATH" \ + --hostname "$HOSTNAME" \ + --memory "$MEMORY" \ + --swap "$SWAP" \ + --cores "$CORES" \ + --ostype debian \ + --storage "$STORAGE_POOL" \ + --rootfs "$STORAGE_POOL:$DISK_SIZE" \ + --net0 "name=eth0,bridge=$BRIDGE,ip=$IP_CONFIG" \ + --unprivileged 1 \ + --onboot 1 \ + --start 0 + +# 6. Mount persistent storage volumes directly to the LXC +echo "[*] Mounting host persistent storage pool into CT $CT_ID..." +pct set "$CT_ID" -mp0 "$HOST_BACKUP_STORAGE/backups,mp=/storage/backups" +pct set "$CT_ID" -mp1 "$HOST_BACKUP_STORAGE/temp,mp=/storage/temp" + +# 7. Start container +echo "[*] Starting CT $CT_ID..." +pct start "$CT_ID" + +echo "" +echo "========================================================================" +echo "[+] Container CT $CT_ID ($HOSTNAME) successfully created and started!" +echo "========================================================================" +echo "" +echo "Next step: Enter container and run the Debian native installer:" +echo " pct enter $CT_ID" +echo " git clone /opt/onever_drive" +echo " bash /opt/onever_drive/deployment/proxmox/02-install-backend-debian.sh" +echo "" diff --git a/deployment/proxmox/02-install-backend-debian.sh b/deployment/proxmox/02-install-backend-debian.sh new file mode 100644 index 0000000..d16c0eb --- /dev/null +++ b/deployment/proxmox/02-install-backend-debian.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — Debian 12 / 13 Native LXC Setup Script +# Executed inside CT 100 (Debian 12 Bookworm / Debian 13 Trixie) +# ============================================================================== + +set -e + +APP_DIR="/opt/onever_drive" +WEB_ROOT="/var/www/onever-drive-web" +STORAGE_DIR="/storage/backups" +TEMP_DIR="/storage/temp" +DB_NAME="onever_drive" +DB_USER="onever_user" +DB_PASS="OneverSecurePass2026!" + +echo "========================================================================" +echo " ONEVER DRIVE — NATIVE DEBIAN 12/13 LXC INSTALLATION " +echo "========================================================================" + +# 1. Update APT and install native dependencies +echo "[1/7] Installing native Debian system packages..." +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends \ + postgresql \ + postgresql-contrib \ + nginx \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + libpq-dev \ + gcc \ + curl \ + git \ + acl \ + ufw \ + ca-certificates \ + gnupg + +# Install Node.js (v20+ LTS) if not installed or outdated +if ! command -v node &> /dev/null; then + echo "[*] Installing Node.js LTS for frontend build..." + mkdir -p /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list + apt-get update + apt-get install -y nodejs +fi + +# 2. Configure native PostgreSQL +echo "[2/7] Configuring PostgreSQL database..." +systemctl start postgresql +systemctl enable postgresql + +sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 || \ + sudo -u postgres psql -c "CREATE DATABASE $DB_NAME;" + +sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER'" | grep -q 1 || \ + sudo -u postgres psql -c "CREATE USER $DB_USER WITH ENCRYPTED PASSWORD '$DB_PASS';" + +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" +sudo -u postgres psql -d "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO $DB_USER;" +sudo -u postgres psql -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;" +sudo -u postgres psql -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;" + +# Ingest DDL schema if tables are not initialized +if [ -f "$APP_DIR/database/schema.sql" ]; then + echo "[*] Ingesting database schema..." + sudo -u postgres psql -d "$DB_NAME" -f "$APP_DIR/database/schema.sql" || true +fi + +# 3. Prepare storage directories & permissions +echo "[3/7] Setting up storage directories..." +mkdir -p "$STORAGE_DIR/clients" +mkdir -p "$TEMP_DIR" +chown -R www-data:www-data "$STORAGE_DIR" "$TEMP_DIR" +chmod 775 "$STORAGE_DIR" +chmod 777 "$TEMP_DIR" + +# 4. Configure Python virtual environment & backend +echo "[4/7] Setting up Python backend virtual environment..." +python3 -m venv "$APP_DIR/venv" +"$APP_DIR/venv/bin/pip" install --upgrade pip +"$APP_DIR/venv/bin/pip" install -r "$APP_DIR/backend/requirements.txt" + +# 5. Build React Web Application +echo "[5/7] Compiling Frontend Web Dashboard..." +if [ -d "$APP_DIR/frontend" ]; then + cd "$APP_DIR/frontend" + npm install + npm run build + mkdir -p "$WEB_ROOT" + cp -r dist/* "$WEB_ROOT/" + chown -R www-data:www-data "$WEB_ROOT" +fi + +# 6. Configure Systemd Service for Backend API +echo "[6/7] Configuring systemd service for OnEver Drive Backend..." +cat < /etc/systemd/system/onever-backend.service +[Unit] +Description=OnEver Drive Central API Backend +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=$APP_DIR/backend +Environment="PYTHONPATH=$APP_DIR/backend" +Environment="DATABASE_URL=postgresql+asyncpg://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME" +Environment="STORAGE_ROOT=$STORAGE_DIR" +Environment="STORAGE_TEMP_ROOT=$TEMP_DIR" +Environment="SECRET_KEY=onever-drive-prod-key-$(openssl rand -hex 16)" +Environment="ENVIRONMENT=production" +Environment="DEBUG=false" +ExecStart=$APP_DIR/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now onever-backend.service + +# 7. Configure Nginx Reverse Proxy +echo "[7/7] Configuring Nginx reverse proxy with chunk streaming and WebSockets..." +cat <<'EOF' > /etc/nginx/sites-available/onever-drive +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Frontend SPA Web Application + root /var/www/onever-drive-web; + index index.html; + + # Allow large / unbounded file chunk streaming + client_max_body_size 0; + + location / { + try_files $uri $uri/ /index.html; + } + + # Backend REST API + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Disable proxy buffering for direct chunk streaming + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # WebSockets Telemetry + location /ws/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} +EOF + +rm -f /etc/nginx/sites-enabled/default +ln -sf /etc/nginx/sites-available/onever-drive /etc/nginx/sites-enabled/onever-drive +nginx -t +systemctl restart nginx +systemctl enable nginx + +echo "" +echo "========================================================================" +echo "[+] OnEver Drive instalado y ejecutándose exitosamente en Debian 12/13!" +echo "========================================================================" +echo "Acceda a la interfaz web en: http://$(hostname -I | awk '{print $1}')" +echo "Credenciales por defecto: admin@oneverdrive.local / Admin1234!" +echo "" diff --git a/deployment/proxmox/03-configure-ssl.sh b/deployment/proxmox/03-configure-ssl.sh new file mode 100644 index 0000000..dd06fd7 --- /dev/null +++ b/deployment/proxmox/03-configure-ssl.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — SSL / HTTPS Configuration for Debian 12 / 13 LXC +# Supports Let's Encrypt (Certbot) or Local High-Entropy Self-Signed Cert +# ============================================================================== + +set -e + +DOMAIN="$1" +SSL_DIR="/etc/ssl/onever-drive" + +if [ -z "$DOMAIN" ]; then + echo "Uso:" + echo " $0 # Para Certbot / Let's Encrypt" + echo " $0 self-signed # Para certificado autofirmado LAN / Intranet" + exit 1 +fi + +if [ "$DOMAIN" != "self-signed" ]; then + echo "[*] Instalando Certbot para Let's Encrypt..." + apt-get update && apt-get install -y certbot python3-certbot-nginx + certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "admin@$DOMAIN" + echo "[+] Certificado Let's Encrypt configurado para $DOMAIN" +else + echo "[*] Generando certificado SSL autofirmado de 4096 bits para Intranet..." + mkdir -p "$SSL_DIR" + openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ + -keyout "$SSL_DIR/server.key" \ + -out "$SSL_DIR/server.crt" \ + -subj "/C=ES/ST=State/L=City/O=OnEver/CN=onever-drive.local" + + cat <<'EOF' > /etc/nginx/sites-available/onever-drive +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name _; + + ssl_certificate /etc/ssl/onever-drive/server.crt; + ssl_certificate_key /etc/ssl/onever-drive/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /var/www/onever-drive-web; + index index.html; + + client_max_body_size 0; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location /ws/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} +EOF + nginx -t + systemctl restart nginx + echo "[+] SSL autofirmado activado en puerto 443 (HTTPS)" +fi diff --git a/deployment/proxmox/04-backup-maintenance.sh b/deployment/proxmox/04-backup-maintenance.sh new file mode 100644 index 0000000..60bd2be --- /dev/null +++ b/deployment/proxmox/04-backup-maintenance.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — System Database & Metadata Self-Backup Script +# Backs up PostgreSQL database and configuration to persistent storage +# ============================================================================== + +set -e + +BACKUP_DEST="/storage/backups/_system_metadata" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +DB_NAME="onever_drive" +RETENTION_DAYS=30 + +mkdir -p "$BACKUP_DEST" + +echo "[*] Creating database dump for $DB_NAME..." +sudo -u postgres pg_dump "$DB_NAME" | gzip > "$BACKUP_DEST/onever_db_$TIMESTAMP.sql.gz" + +echo "[*] Backing up configuration files..." +tar -czf "$BACKUP_DEST/config_$TIMESTAMP.tar.gz" /etc/nginx/sites-available/onever-drive /etc/systemd/system/onever-backend.service 2>/dev/null || true + +echo "[*] Pruning old database dumps older than $RETENTION_DAYS days..." +find "$BACKUP_DEST" -type f -name "*.gz" -mtime +$RETENTION_DAYS -delete + +echo "[+] Self-backup completed: $BACKUP_DEST/onever_db_$TIMESTAMP.sql.gz" diff --git a/deployment/proxmox/setup-lxc-backend.sh b/deployment/proxmox/setup-lxc-backend.sh new file mode 100644 index 0000000..b9a8591 --- /dev/null +++ b/deployment/proxmox/setup-lxc-backend.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — Proxmox VE LXC 1: Backend API Provisioning Script +# Target: Debian 12 Bookworm LXC (CT 100) +# ============================================================================== + +set -e + +echo "=== [1/6] Actualizando paquetes base del sistema Debian 12 ===" +apt-get update && apt-get upgrade -y +apt-get install -y curl git ufw python3 python3-pip python3-venv postgresql postgresql-contrib nginx + +echo "=== [2/6] Configurando base de datos PostgreSQL ===" +systemctl start postgresql +systemctl enable postgresql + +sudo -u postgres psql -c "CREATE DATABASE onever_drive;" || true +sudo -u postgres psql -c "CREATE USER onever_user WITH ENCRYPTED PASSWORD 'OneverSecurePass2026!';" || true +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE onever_drive TO onever_user;" || true +sudo -u postgres psql -d onever_drive -c "GRANT ALL ON SCHEMA public TO onever_user;" || true + +echo "=== [3/6] Preparando directorios de aplicación y almacenamiento ===" +mkdir -p /opt/onever_drive +mkdir -p /storage/backups/clients +mkdir -p /storage/temp +chown -R www-data:www-data /storage + +echo "=== [4/6] Configurando entorno virtual de Python ===" +python3 -m venv /opt/onever_drive/venv +/opt/onever_drive/venv/pip install --upgrade pip + +# Copiar archivos de backend (asumiendo clonado en /opt/onever_drive) +if [ -f "/opt/onever_drive/backend/requirements.txt" ]; then + /opt/onever_drive/venv/pip install -r /opt/onever_drive/backend/requirements.txt +fi + +echo "=== [5/6] Creando servicio systemd para OnEver Drive Backend ===" +cat < /etc/systemd/system/onever-backend.service +[Unit] +Description=OnEver Drive Central API Backend +After=network.target postgresql.service + +[Service] +User=root +WorkingDirectory=/opt/onever_drive/backend +Environment="DATABASE_URL=postgresql+asyncpg://onever_user:OneverSecurePass2026!@127.0.0.1:5432/onever_drive" +Environment="STORAGE_ROOT=/storage/backups" +Environment="STORAGE_TEMP_ROOT=/storage/temp" +Environment="SECRET_KEY=$(openssl rand -hex 32)" +Environment="ENVIRONMENT=production" +ExecStart=/opt/onever_drive/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable onever-backend.service +systemctl start onever-backend.service + +echo "=== [6/6] Provisionamiento completado exitosamente ===" +echo "El backend de OnEver Drive está activo en http://127.0.0.1:8000" diff --git a/deployment/proxmox/setup-lxc-storage.sh b/deployment/proxmox/setup-lxc-storage.sh new file mode 100644 index 0000000..be99a48 --- /dev/null +++ b/deployment/proxmox/setup-lxc-storage.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# ============================================================================== +# OnEver Drive — Proxmox VE LXC 2: Dedicated Storage Node Setup Script +# Target: Debian 12 Bookworm LXC (CT 101) +# ============================================================================== + +set -e + +echo "=== [1/4] Configurando nodo de almacenamiento Proxmox VE ===" +apt-get update && apt-get install -y nfs-kernel-server rsync zfsutils-linux acl + +echo "=== [2/4] Creando jerarquía de almacenamiento y permisos de aislamiento ===" +mkdir -p /storage/backups/clients +mkdir -p /storage/temp +chmod 750 /storage/backups +chmod 770 /storage/temp + +echo "=== [3/4] Configuración de punto de montaje persistente ===" +# En Proxmox Host (pve), montar el dataset ZFS o volumen LVM con: +# pct set 100 -mp0 /mnt/pve/backup-zfs/backups,mp=/storage/backups + +cat <` + `X-Device-Token: ` +- **Body**: + ```json + { + "filename": "database_production.bak", + "file_size": 12582912, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "chunk_size": 4194304, + "job_id": 1 + } + ``` +- **Response**: + ```json + { + "session_code": "6f2e8b1a-...", + "filename": "database_production.bak", + "file_size": 12582912, + "chunk_size": 4194304, + "total_chunks": 3, + "received_chunks": [0], + "status": "UPLOADING" + } + ``` + +### `POST /api/upload/{session_code}/chunk` +Envía los bytes de un bloque específico. +- **Headers**: + `X-Device-Id: ` + `X-Device-Token: ` + `X-Chunk-Index: 1` + `X-Chunk-SHA256: ` +- **Body**: Raw binary bytes +- **Response**: + ```json + { + "chunk_index": 1, + "is_received": true, + "total_received": 2, + "total_chunks": 3, + "progress_percent": 66.67 + } + ``` + +### `GET /api/upload/{session_code}/status` +Devuelve el estado de la sesión y la lista de chunks pendientes de subida. + +### `POST /api/upload/{session_code}/complete` +Solicita el ensamblado secuencial del archivo y la verificación de integridad SHA-256 total. +- **Response**: + ```json + { + "session_code": "6f2e8b1a-...", + "filename": "database_production.bak", + "relative_path": "clients/CLIENT-0001/JOB-001/20260813_160000_database_production.bak", + "file_size": 12582912, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "SUCCESS" + } + ``` + +--- + +## 4. WebSockets Telemetría en Tiempo Real + +### Endpoint: `/ws/telemetry` +Eventos transmitidos: +- `UPLOAD_PROGRESS`: `{ "session_code", "filename", "client_id", "chunk_index", "received_chunks", "total_chunks", "progress_percent" }` +- `UPLOAD_COMPLETED`: `{ "session_code", "filename", "client_id", "file_size", "sha256", "status" }` +- `CLIENT_REGISTERED`: `{ "id", "client_code", "name", "hostname", "status" }` +- `EVENT_LOG`: `{ "id", "timestamp", "event_type", "severity", "message" }` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b4cd768 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,97 @@ +# OnEver Drive — Arquitectura Técnica y Especificación + +## 1. Visión General +**OnEver Drive** es una plataforma de copia de seguridad y sincronización centralizada de nivel empresarial, diseñada para respaldar de manera desatendida y segura servidores y estaciones de trabajo Windows hacia un cluster de virtualización **Proxmox VE**. + +La plataforma divide responsabilidades en dos capas desacopladas: +1. **Backend API + Interfaz Web Centralizada**: Administra clientes, define trabajos de respaldo, supervisa telemetría en tiempo real y ejecuta políticas de retención. +2. **Agente Windows (Servicio de Fondo)**: Detecta cambios en carpetas locales, valida estabilidad y bloqueo de archivos (especialmente volcados masivos `.bak` de Microsoft SQL Server), y transfiere datos mediante bloques (chunks) a través de HTTPS con capacidad de reanudación inmediata ante cortes. + +--- + +## 2. Diagrama de Arquitectura en Proxmox VE + +```text + INTERNET / RED LOCAL + │ + │ HTTPS / WebSockets + ▼ + ┌───────────────────────────────────────────────┐ + │ SERVIDOR CENTRAL │ + │ PROXMOX VE │ + └───────────────────────┬───────────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + │ │ + ▼ ▼ + ┌───────────────────────┐ ┌───────────────────────┐ + │ LXC 1: BACKEND │ │ LXC 2: STORAGE │ + │ (Debian 12/13 CT100)│ │ (Debian 12/13 CT101)│ + │ │ │ │ + │ • FastAPI REST API │ Punto de Montaje │ • Almacenamiento ZFS │ + │ • PostgreSQL 16 Nativo│ ────────────────────► │ • Aislamiento /client │ + │ • WebSockets Engine │ │ • Directorio Temporal │ + │ • Dashboard Web UI │ │ • Deduplicación / LVM │ + └───────────┬───────────┘ └───────────────────────┘ + │ + │ HTTPS (Streaming Chunks 4MB + Hashing) + │ + ┌────────┴────────┐ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Windows 11 │ │WinServer 2022│ + │ Backup Agent │ │ Backup Agent │ + └──────────────┘ └──────────────┘ +``` + +--- + +## 3. Protocolo de Transferencia por Bloques (Chunks) y Reanudación + +La transferencia de archivos grandes (ej. 50 GB) no utiliza envíos en una sola petición `POST` multipart monolítica, sino un protocolo secuenciado de subida por bloques: + +```text +[Archivo database.bak (12 GB)] + │ + ├── Chunk 000000 (4 MB) ──► SHA256 Chunk ──► Guardado en temp/{session}/00000000.chunk + ├── Chunk 000001 (4 MB) ──► SHA256 Chunk ──► Guardado en temp/{session}/00000001.chunk + ├── ... + └── Chunk 003000 (4 MB) ──► SHA256 Chunk ──► Guardado en temp/{session}/00300000.chunk + │ + ▼ + [Ensamblado Secuencial] + │ + ▼ + [Validación SHA-256 Full] + │ + ▼ + /storage/backups/clients/{client}/{job}/ +``` + +### Ciclo de Vida de una Sesión de Subida + +1. **Detección y Estabilidad (`is_file_stable`)**: + - El agente comprueba que el archivo no posea un bloqueo exclusivo de escritura (`EACCES`/`EBUSY`) por parte de SQL Server y que su tamaño permanezca invariante durante la ventana configurada (`min_stable_time_seconds: 60s`). +2. **Inicio o Reanudación (`POST /api/upload/session`)**: + - El agente envía el hash SHA-256 total precalculado, tamaño en bytes y nombre. + - El backend busca si ya existe una sesión en progreso para ese hash. Si existe, responde con `received_chunks: [0, 1, 2, ...]`. +3. **Transmisión de Chunks Faltantes (`POST /api/upload/{session}/chunk`)**: + - El agente transmite exclusivamente los bloques que el servidor aún no tiene. + - El servidor almacena el bloque y valida su hash SHA-256 individual. + - Cada bloque recibido emite un evento WebSocket `UPLOAD_PROGRESS` que actualiza el Dashboard en tiempo real. +4. **Ensamblado y Verificación de Integridad (`POST /api/upload/{session}/complete`)**: + - El servidor concatena los bloques en streaming hacia el volumen final. + - Calcula el hash SHA-256 en streaming del archivo ensamblado y lo compara contra el hash declarado. + - Si coincide: Marca el archivo como `SUCCESS`, actualiza la cuota del cliente y ejecuta la política de retención. + - Si difiere: Elimina el archivo corrupto y solicita retransmisión. + +--- + +## 4. Aislamiento Estricto Multicliente + +- Cada agente Windows posee credenciales únicas revocables (`X-Device-Id` y `X-Device-Token`). +- Los respaldos se estructuran físicamente en: + `/storage/backups/clients/{CLIENT_CODE}/{JOB_CODE}/{TIMESTAMP}_{FILENAME}` +- Ningún cliente puede listar, sobrescribir ni acceder a las sesiones o archivos de otro cliente (validado a nivel de base de datos y sistema de archivos). +- La eliminación accidental de un archivo local en Windows **nunca** borra los backups remotos en el servidor. diff --git a/docs/proxmox_deployment_guide.md b/docs/proxmox_deployment_guide.md new file mode 100644 index 0000000..830b93e --- /dev/null +++ b/docs/proxmox_deployment_guide.md @@ -0,0 +1,111 @@ +# OnEver Drive — Guía de Despliegue Nativo en Proxmox VE (LXC Debian 12 / 13) + +Este documento detalla la instalación paso a paso en **Proxmox Virtual Environment (PVE)** ejecutando la aplicación **100% nativa en contenedores Linux (LXC) sobre Debian 12 (Bookworm) o Debian 13 (Trixie)**, sin Docker ni capas intermedias. + +--- + +## 1. Topología del Sistema en Proxmox VE + +```text + ┌─────────────────────────────────────────────────────────┐ + │ PROXMOX VE HOST │ + │ │ + │ ZFS / LVM Pool: /mnt/pve/backup-pool │ + │ ├── backups/ (Repositorio de respaldos) │ + │ └── temp/ (Directorio temporal de chunks) │ + └────────────────────────────┬────────────────────────────┘ + │ Mountpoints (-mp0, -mp1) + ▼ + ┌─────────────────────────────────────────────────────────┐ + │ CT 100 — onever-backend (Debian 12/13) │ + │ │ + │ • FastAPI Backend Service (/etc/systemd/system/...) │ + │ • PostgreSQL 15/16 Nativo (Base de datos transaccional)│ + │ • Nginx Reverse Proxy (Streaming Chunks + WebSockets) │ + │ • Frontend Web Dashboard (/var/www/onever-drive-web) │ + │ • Python Virtualenv (/opt/onever_drive/venv) │ + └─────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Paso 1: Creación del Contenedor LXC en Proxmox Host + +Ejecute en la consola Shell del nodo Proxmox VE (root): + +```bash +# 1. Copiar o clonar scripts de aprovisionamiento +git clone /tmp/onever_drive + +# 2. Ejecutar creación automática de LXC y montaje de almacenamiento +bash /tmp/onever_drive/deployment/proxmox/01-create-lxc-pve-host.sh +``` + +El script: +- Descarga la plantilla oficial `debian-12-standard` o `debian-13-standard`. +- Crea el contenedor **CT 100** (2 GB RAM, 2 Cores, 20 GB disco del sistema). +- Monta directamente el pool de almacenamiento del Host hacia `/storage/backups` y `/storage/temp` en el LXC. +- Inicia el contenedor. + +--- + +## 3. Paso 2: Instalación Nativa en el Contenedor (Debian 12 / 13) + +Ingrese al contenedor e inicie el instalador nativo: + +```bash +# En el Host Proxmox: +pct enter 100 + +# Dentro del contenedor Debian 12 / 13: +git clone /opt/onever_drive +bash /opt/onever_drive/deployment/proxmox/02-install-backend-debian.sh +``` + +El instalador automatiza todo el proceso: +1. Instala paquetes nativos: `postgresql`, `nginx`, `python3`, `python3-venv`, `nodejs`, `npm`. +2. Inicializa PostgreSQL y crea la base de datos `onever_drive` con el esquema DDL e índices. +3. Configura el entorno virtual de Python y dependencias en `/opt/onever_drive/venv`. +4. Compila la interfaz Web React y la ubica en `/var/www/onever-drive-web`. +5. Instala el servicio `onever-backend.service` en **systemd** y lo inicia automáticamente. +6. Configura **Nginx** con soporte para streaming de bloques por chunks ilimitados y WebSockets. + +--- + +## 4. Paso 3: Configuración de Certificado SSL / HTTPS + +Para habilitar HTTPS en el puerto 443: + +```bash +# Para dominio público con Let's Encrypt (Certbot): +bash /opt/onever_drive/deployment/proxmox/03-configure-ssl.sh backup.midominio.com + +# Para entorno LAN / Intranet (Certificado autofirmado 4096 bits): +bash /opt/onever_drive/deployment/proxmox/03-configure-ssl.sh self-signed +``` + +--- + +## 5. Paso 4: Mantenimiento y Backups del Sistema + +Para programar el backup diario de la base de datos PostgreSQL de OnEver Drive hacia el almacenamiento persistente: + +```bash +# Agregar tarea en cron en CT 100: +crontab -e + +# Agregar línea (ejecutar diariamente a las 01:00 AM): +0 1 * * * bash /opt/onever_drive/deployment/proxmox/04-backup-maintenance.sh +``` + +--- + +## 6. Comprobación y Estado de Servicios + +Verificar que todos los servicios nativos están activos: + +```bash +systemctl status onever-backend.service +systemctl status postgresql +systemctl status nginx +``` diff --git a/docs/windows_agent_manual.md b/docs/windows_agent_manual.md new file mode 100644 index 0000000..4d7fef2 --- /dev/null +++ b/docs/windows_agent_manual.md @@ -0,0 +1,84 @@ +# OnEver Drive — Manual del Agente Windows (PyQt6 & System Tray) + +## 1. Visión General +El **Agente de Windows de OnEver Drive** es una aplicación de escritorio nativa moderna desarrollada en **PyQt6**, empaquetada como ejecutable `.exe` y diseñada para ejecutarse silenciosamente en el **Área de Notificaciones (System Tray)** de Windows con interfaz gráfica completa para: + +1. **Apuntar y conectar con el servidor central de backup** (URL del servidor + Código de registro con test de latencia). +2. **Elegir carpetas de backup locales visualmente** mediante el explorador de Windows (`QFileDialog`), estableciendo filtros (`*.bak`, `*.mdf`), frecuencia y estabilidad de archivos. +3. **Monitorear transferencias por bloques (chunks de 4 MB)** en tiempo real con barras de progreso, cálculo de hash SHA-256 y notificaciones nativas de Windows. + +--- + +## 2. Interfaz Gráfica PyQt6 + +```text + ┌────────────────────────────────────────────────────────────────────────┐ + │ OnEver Drive — Agente de Backup Windows ● Conectado │ + ├────────────────────────────────────────────────────────────────────────┤ + │ [Dashboard] [Carpetas de Backup] [Servidor & Config] [Historial] │ + ├────────────────────────────────────────────────────────────────────────┤ + │ │ + │ Transferencia en Vivo (Motor de Chunks) │ + │ Subiendo database_production.bak... │ + │ [████████████████████████████░░░░░░░░] 68.4% (17 / 25 Chunks) │ + │ Chunks: 17/25 | Velocidad: 42.1 MB/s | SHA-256: e3b0c44298fc... │ + │ │ + │ Información del Dispositivo │ + │ • Cliente ID: CLIENT-0001 (Servidor SQL Producción) │ + │ • Servidor Proxmox: https://backup.midominio.com │ + │ • Carpetas en Monitoreo: 3 carpetas locales │ + │ │ + │ [ ▶ Iniciar Sincronización Manual Ahora ] │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Pestañas y Funcionalidades + +### 📁 Pestaña 1: Carpetas de Backup (Selector Visual) +Permite gestionar las carpetas de Windows que el agente respaldará automáticamente: +- **Añadir Carpeta...**: Abre el selector visual de Windows para elegir la carpeta deseada (ej. `C:\SQLBackups`, `D:\DatosEmpresa`). +- **Configuración de la Carpeta**: + - **Filtros de Archivo**: `*.bak,*.mdf` (o `*.*`). + - **Frecuencia de Sondeo**: Intervalo en minutos (ej. cada 60 min). + - **Estabilidad de Archivo (Locks)**: Ventana de seguridad en segundos (ej. 60s) para garantizar que SQL Server finalizó el volcado antes de iniciar la transferencia por bloques. +- **Respaldar / Eliminar**: Botones dedicados para iniciar un respaldo inmediato de la carpeta o eliminarla del monitoreo. + +### 🌐 Pestaña 2: Servidor & Configuración +- **URL Servidor**: Ingrese la dirección del servidor Proxmox VE (ej. `http://192.168.1.100:8000` o `https://backup.midominio.com`). +- **Probar Conexión**: Comprueba la conectividad de red con el backend y muestra la latencia en milisegundos. +- **Código de Registro**: Ingrese el código de un solo uso generado en el Dashboard Web (`OED-XXXX-XXXX`). +- **Registrar Dispositivo**: Vincula criptográficamente la máquina Windows generando un `Device ID` y token secreto único. +- **Preferencias de Notificaciones (No Invasivas)**: + - `[x] Habilitar notificaciones en el Área de Notificaciones (System Tray)` + - `[x] Notificar únicamente cuando INICIA un proceso de respaldo` + - `[x] Notificar únicamente cuando FINALIZA con éxito (Confirmación SHA-256)` + - `[x] Notificar en caso de error o pérdida de conexión` + *(Las actualizaciones de progreso intermedias se visualizan de manera silenciosa en la barra de la interfaz sin emitir globos emergentes).* + +### 📊 Pestaña 3: Dashboard & Telemetría +- Medidor en tiempo real del progreso de subida por bloques. +- Notificaciones de confirmación de integridad SHA-256 tras cada ensamblado. + +### 📜 Pestaña 4: Historial de Archivos +- Registro histórico de todos los archivos respaldados localmente con su tamaño, fecha y checksum SHA-256. + +--- + +## 4. Ejecutables Disponibles + +Los ejecutables listos para su distribución se ubican en: +- **Standalone Portable (Un solo archivo):** + [`windows-agent/dist/OnEverDriveAgent-Standalone.exe`](file:///c:/Workspaces/onever_drive/windows-agent/dist/OnEverDriveAgent-Standalone.exe) +- **Carpeta de Distribución:** + [`windows-agent/dist/OnEverDriveAgent/OnEverDriveAgent.exe`](file:///c:/Workspaces/onever_drive/windows-agent/dist/OnEverDriveAgent/OnEverDriveAgent.exe) +- **Lanzador rápido con doble clic:** + [`windows-agent/start_tray_agent.bat`](file:///c:/Workspaces/onever_drive/windows-agent/start_tray_agent.bat) + +--- + +## 5. Comportamiento en la Bandeja del Sistema (System Tray) + +- Al cerrar la ventana principal (botón `X`), la aplicación se **minimiza al área de notificaciones** junto al reloj sin interrumpir las transferencias programadas. +- Al hacer **doble clic** o clic derecho en el icono de OnEver Drive en el System Tray, se abre instantáneamente el panel de control. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8b0c28b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + OnEver Drive — Centralized Enterprise Backup & Sync + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..98c3f08 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1869 @@ +{ + "name": "onever-drive-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "onever-drive-dashboard", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.16.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..35e57e3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "onever-drive-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.16.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c84a9f5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { Topbar } from './components/Topbar'; +import { DashboardView } from './pages/DashboardView'; +import { ClientsView } from './pages/ClientsView'; +import { JobsView } from './pages/JobsView'; +import { RestoreView } from './pages/RestoreView'; +import { EventsView } from './pages/EventsView'; +import { LoginView } from './pages/LoginView'; +import { ActiveUpload } from './components/LiveTransferMeter'; +import { + api, + DashboardStats, + ClientItem, + BackupJobItem, + EventLogItem, + getAuthToken, + getCurrentUser, + setAuthToken +} from './services/api'; +import { wsClient } from './services/websocket'; + +export const App: React.FC = () => { + const [currentUser, setCurrentUser] = useState(getCurrentUser()); + const [authToken, setTokenState] = useState(getAuthToken()); + + const [currentTab, setCurrentTab] = useState('dashboard'); + const [isWsConnected, setIsWsConnected] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [stats, setStats] = useState(null); + const [clients, setClients] = useState([]); + const [jobs, setJobs] = useState([]); + const [events, setEvents] = useState([]); + const [activeUploads, setActiveUploads] = useState([]); + + const handleLoginSuccess = (user: any) => { + setCurrentUser(user); + setTokenState(getAuthToken()); + loadData(); + }; + + const handleLogout = () => { + setAuthToken(null); + setCurrentUser(null); + setTokenState(null); + setStats(null); + setClients([]); + setJobs([]); + setEvents([]); + }; + + const loadData = async () => { + if (!getAuthToken()) return; + setIsLoading(true); + try { + const [statsRes, clientsRes, jobsRes, eventsRes] = await Promise.all([ + api.getStats().catch(() => null), + api.getClients().catch(() => []), + api.getJobs().catch(() => []), + api.getEvents(50).catch(() => []), + ]); + + if (statsRes) setStats(statsRes); + setClients(clientsRes); + setJobs(jobsRes); + setEvents(eventsRes); + } catch (err) { + console.error('Error loading dashboard data:', err); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + const handleUnauthorized = () => { + handleLogout(); + }; + + window.addEventListener('oed_unauthorized', handleUnauthorized); + + if (authToken) { + loadData(); + wsClient.connect(); + + const unsubscribe = wsClient.subscribe((evt) => { + if (evt.type === 'WS_CONNECTED') { + setIsWsConnected(true); + } else if (evt.type === 'WS_DISCONNECTED') { + setIsWsConnected(false); + } else if (evt.type === 'UPLOAD_PROGRESS') { + const upload = evt.data as ActiveUpload; + setActiveUploads((prev) => { + const index = prev.findIndex((u) => u.session_code === upload.session_code); + if (index >= 0) { + const next = [...prev]; + next[index] = { ...next[index], ...upload }; + return next; + } + return [upload, ...prev]; + }); + } else if (evt.type === 'UPLOAD_COMPLETED') { + const sessionCode = evt.data.session_code; + setTimeout(() => { + setActiveUploads((prev) => prev.filter((u) => u.session_code !== sessionCode)); + loadData(); + }, 2000); + } else if (evt.type === 'EVENT_LOG') { + setEvents((prev) => [evt.data, ...prev.slice(0, 49)]); + } else if (evt.type === 'CLIENT_REGISTERED' || evt.type === 'CLIENT_HEARTBEAT') { + api.getClients().then(setClients).catch(() => {}); + } + }); + + const interval = setInterval(() => { + if (getAuthToken()) { + api.getStats().then((s) => s && setStats(s)).catch(() => {}); + } + }, 15000); + + return () => { + unsubscribe(); + clearInterval(interval); + window.removeEventListener('oed_unauthorized', handleUnauthorized); + }; + } + + return () => { + window.removeEventListener('oed_unauthorized', handleUnauthorized); + }; + }, [authToken]); + + // If not authenticated, render Login Screen + if (!authToken || !currentUser) { + return ; + } + + const getTabTitle = () => { + switch (currentTab) { + case 'dashboard': + return 'Panel Principal & Telemetría'; + case 'clients': + return 'Clientes Windows'; + case 'jobs': + return 'Trabajos de Backup'; + case 'restore': + return 'Explorador & Restore'; + case 'events': + return 'Auditoría & Logs'; + default: + return 'OnEver Drive'; + } + }; + + return ( +
+ + +
+ + +
+ {currentTab === 'dashboard' && ( + setCurrentTab('clients')} + /> + )} + + {currentTab === 'clients' && ( + + )} + + {currentTab === 'jobs' && ( + + )} + + {currentTab === 'restore' && ( + + )} + + {currentTab === 'events' && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/components/LiveTransferMeter.tsx b/frontend/src/components/LiveTransferMeter.tsx new file mode 100644 index 0000000..f5650da --- /dev/null +++ b/frontend/src/components/LiveTransferMeter.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { UploadCloud, CheckCircle2, AlertTriangle } from 'lucide-react'; + +export interface ActiveUpload { + session_code: string; + filename: string; + client_id: number; + chunk_index: number; + received_chunks: number; + total_chunks: number; + progress_percent: number; + status?: string; + speed_mb?: number; +} + +interface LiveTransferMeterProps { + uploads: ActiveUpload[]; +} + +export const LiveTransferMeter: React.FC = ({ uploads }) => { + if (uploads.length === 0) { + return ( +
+ +

Sin transferencias activas

+

+ Los agentes Windows transmitirán bloques de archivos automáticamente según sus trabajos programados. +

+
+ ); + } + + return ( +
+
+
+
+

Transferencias en Vivo ({uploads.length})

+
+ Motor de Chunks Activo +
+ +
+ {uploads.map((upload) => { + const isComplete = upload.progress_percent >= 100; + return ( +
+
+
+ + {upload.filename} + + + Cliente #{upload.client_id} + +
+
+ + {upload.received_chunks} / {upload.total_chunks} Chunks ({upload.progress_percent}%) + + {isComplete && } +
+
+ +
+
+
+ +
+ Sesión: {upload.session_code.substring(0, 18)}... + {isComplete ? 'Ensamblando & Verificando SHA-256...' : 'Transmitiendo bloques de 4 MB'} +
+
+ ); + })} +
+
+ ); +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..10f4da9 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { + LayoutDashboard, + HardDrive, + Layers, + RotateCcw, + FileText, + ShieldCheck, + Server +} from 'lucide-react'; + +interface SidebarProps { + currentTab: string; + setCurrentTab: (tab: string) => void; + isWsConnected: boolean; +} + +export const Sidebar: React.FC = ({ currentTab, setCurrentTab, isWsConnected }) => { + const menuItems = [ + { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { id: 'clients', label: 'Clientes Windows', icon: HardDrive }, + { id: 'jobs', label: 'Trabajos de Backup', icon: Layers }, + { id: 'restore', label: 'Explorador & Restore', icon: RotateCcw }, + { id: 'events', label: 'Auditoría & Logs', icon: FileText }, + ]; + + return ( + + ); +}; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx new file mode 100644 index 0000000..8484a8f --- /dev/null +++ b/frontend/src/components/Topbar.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { RefreshCw, Server, User as UserIcon, LogOut } from 'lucide-react'; + +interface TopbarProps { + title: string; + user: any | null; + onRefresh: () => void; + onLogout: () => void; + isLoading: boolean; +} + +export const Topbar: React.FC = ({ title, user, onRefresh, onLogout, isLoading }) => { + return ( +
+
+

{title}

+
+ +
+
+ + LXC Proxmox Node +
+ + + +
+ + + {user?.email || 'admin@oneverdrive.local'} + +
+ + +
+
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7ed8625 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,569 @@ +:root { + --bg-main: #090d16; + --bg-card: rgba(17, 24, 39, 0.75); + --bg-card-hover: rgba(31, 41, 55, 0.85); + --bg-subtle: rgba(255, 255, 255, 0.03); + --border-color: rgba(255, 255, 255, 0.08); + --border-focus: rgba(6, 182, 212, 0.5); + + --text-main: #f3f4f6; + --text-muted: #9ca3af; + --text-dim: #6b7280; + + --accent-cyan: #06b6d4; + --accent-cyan-glow: rgba(6, 182, 212, 0.25); + --accent-indigo: #6366f1; + --accent-emerald: #10b981; + --accent-emerald-glow: rgba(16, 185, 129, 0.25); + --accent-amber: #f59e0b; + --accent-rose: #f43f5e; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + + --shadow-card: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + --shadow-glow: 0 0 20px rgba(6, 182, 212, 0.15); + + --font-main: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-main); + background-color: var(--bg-main); + color: var(--text-main); + min-height: 100vh; + overflow-x: hidden; + background-image: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%), + radial-gradient(at 100% 0%, rgba(6, 182, 212, 0.10) 0px, transparent 50%), + radial-gradient(at 50% 100%, rgba(16, 185, 129, 0.08) 0px, transparent 50%); + background-attachment: fixed; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* App Container Layout */ +.app-layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* Sidebar Navigation */ +.sidebar { + width: 260px; + background: rgba(13, 19, 33, 0.85); + backdrop-filter: blur(16px); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 24px 16px; + flex-shrink: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 24px 8px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; +} + +.brand-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo)); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 16px var(--accent-cyan-glow); + color: white; +} + +.brand-text h1 { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(to right, #fff, #93c5fd); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.brand-text span { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent-cyan); + font-weight: 600; +} + +.nav-links { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.nav-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: var(--radius-md); + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); + font-size: 0.92rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + text-align: left; +} + +.nav-btn:hover { + background: var(--bg-subtle); + color: var(--text-main); + border-color: rgba(255, 255, 255, 0.05); +} + +.nav-btn.active { + background: linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(99, 102, 241, 0.05)); + border-color: rgba(6, 182, 212, 0.3); + color: #fff; + font-weight: 600; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.nav-btn.active svg { + color: var(--accent-cyan); +} + +.sidebar-footer { + padding-top: 16px; + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; +} + +.ws-status-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.3); + font-size: 0.78rem; + color: var(--text-muted); +} + +.pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-emerald); + box-shadow: 0 0 8px var(--accent-emerald); + animation: pulse 2s infinite; +} + +.pulse-dot.offline { + background: var(--accent-rose); + box-shadow: 0 0 8px var(--accent-rose); + animation: none; +} + +@keyframes pulse { + 0% { transform: scale(0.95); opacity: 0.7; } + 50% { transform: scale(1.15); opacity: 1; } + 100% { transform: scale(0.95); opacity: 0.7; } +} + +/* Main Content Area */ +.main-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.topbar { + height: 70px; + padding: 0 32px; + background: rgba(13, 19, 33, 0.6); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.topbar-title h2 { + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.content-scrollable { + flex: 1; + overflow-y: auto; + padding: 32px; +} + +/* Glassmorphism Card System */ +.glass-card { + background: var(--bg-card); + backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 24px; + box-shadow: var(--shadow-card); + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; +} + +.glass-card:hover { + border-color: rgba(255, 255, 255, 0.15); +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 28px; +} + +.stat-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + position: relative; + overflow: hidden; +} + +.stat-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--accent-cyan); +} + +.stat-card.emerald::after { background: var(--accent-emerald); } +.stat-card.indigo::after { background: var(--accent-indigo); } +.stat-card.amber::after { background: var(--accent-amber); } + +.stat-info h3 { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} + +.stat-number { + font-size: 1.85rem; + font-weight: 800; + letter-spacing: -0.03em; + color: #fff; + font-family: var(--font-mono); +} + +.stat-sub { + font-size: 0.78rem; + color: var(--text-dim); + margin-top: 4px; +} + +.stat-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + background: var(--bg-subtle); + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-cyan); +} + +/* Progress Bar / Storage Meter */ +.storage-meter-container { + margin-top: 12px; +} + +.progress-track { + height: 8px; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-indigo)); + border-radius: 999px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 0 12px var(--accent-cyan-glow); +} + +.progress-fill.animated { + background: linear-gradient(90deg, #06b6d4, #6366f1, #06b6d4); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-radius: var(--radius-md); + font-size: 0.88rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo)); + color: #fff; + box-shadow: 0 4px 14px var(--accent-cyan-glow); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px var(--accent-cyan-glow); +} + +.btn-secondary { + background: var(--bg-subtle); + border-color: var(--border-color); + color: var(--text-main); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); +} + +.btn-danger { + background: rgba(244, 63, 94, 0.15); + border-color: rgba(244, 63, 94, 0.3); + color: #fda4af; +} + +.btn-danger:hover { + background: rgba(244, 63, 94, 0.25); +} + +/* Tables */ +.table-container { + overflow-x: auto; + margin-top: 16px; +} + +.modern-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + text-align: left; +} + +.modern-table th { + padding: 14px 18px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + border-bottom: 1px solid var(--border-color); +} + +.modern-table td { + padding: 16px 18px; + font-size: 0.9rem; + color: var(--text-main); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.modern-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-online { + background: rgba(16, 185, 129, 0.15); + color: #34d399; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.badge-offline { + background: rgba(107, 114, 128, 0.2); + color: #9ca3af; + border: 1px solid rgba(107, 114, 128, 0.3); +} + +.badge-syncing { + background: rgba(6, 182, 212, 0.15); + color: #38bdf8; + border: 1px solid rgba(6, 182, 212, 0.3); +} + +.badge-error { + background: rgba(244, 63, 94, 0.15); + color: #f87171; + border: 1px solid rgba(244, 63, 94, 0.3); +} + +.hash-badge { + font-family: var(--font-mono); + font-size: 0.76rem; + background: rgba(0, 0, 0, 0.4); + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.08); + color: #67e8f9; +} + +/* Modal Dialogs */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 20px; +} + +.modal-card { + width: 100%; + max-width: 580px; + background: #111827; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-xl); + padding: 28px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); + animation: modalIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; +} + +.form-input { + width: 100%; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: #fff; + font-family: inherit; + font-size: 0.92rem; + transition: border-color 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: 0 0 0 3px var(--accent-cyan-glow); +} + +.code-box { + background: #060911; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + font-family: var(--font-mono); + font-size: 0.85rem; + color: #38bdf8; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + word-break: break-all; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..d0aea34 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/ClientsView.tsx b/frontend/src/pages/ClientsView.tsx new file mode 100644 index 0000000..a810707 --- /dev/null +++ b/frontend/src/pages/ClientsView.tsx @@ -0,0 +1,288 @@ +import React, { useState } from 'react'; +import { + Plus, + HardDrive, + Copy, + Check, + ShieldAlert, + Trash2, + Laptop, + Server as ServerIcon, + X +} from 'lucide-react'; +import { ClientItem, api } from '../services/api'; + +interface ClientsViewProps { + clients: ClientItem[]; + onRefresh: () => void; +} + +export const ClientsView: React.FC = ({ clients, onRefresh }) => { + const [showModal, setShowModal] = useState(false); + const [clientHint, setClientHint] = useState(''); + const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null); + const [isCopied, setIsCopied] = useState(false); + const [loading, setLoading] = useState(false); + + 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 handleGenerateCode = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await api.createRegistrationCode(clientHint); + setGeneratedCode(res); + } catch (err: any) { + alert(`Error generating registration code: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const handleRevoke = async (client: ClientItem) => { + if (confirm(`¿Estás seguro de revocar las credenciales para el cliente ${client.name} (${client.client_code})?`)) { + try { + await api.revokeClient(client.id); + onRefresh(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + } + }; + + const handleDelete = async (client: ClientItem) => { + if (confirm(`¿Eliminar definitivamente el cliente ${client.name} y todos sus registros?`)) { + try { + await api.deleteClient(client.id); + onRefresh(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + } + }; + + const serverUrl = window.location.origin; + const psCommand = generatedCode + ? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"` + : ''; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + return ( +
+
+
+

Clientes Windows Registrados

+

+ Administración centralizada de agentes Windows 10, 11 y Windows Server +

+
+ +
+ +
+
+ + + + + + + + + + + + + + + {clients.map((client) => { + const isOnline = client.status === 'ONLINE'; + return ( + + + + + + + + + + + ); + })} + {clients.length === 0 && ( + + + + )} + +
Cliente IDNombre / HostnameSistema OperativoDirección IPEstadoEspacio UtilizadoÚltima ConexiónAcciones
+ + {client.client_code} + + +
+ {client.os_info?.includes('Server') ? ( + + ) : ( + + )} +
+
{client.name}
+
+ {client.hostname || 'Desconocido'} • v{client.agent_version} +
+
+
+
+ {client.os_info || 'Windows'} + + {client.ip_address || '—'} + + + {client.status} + + + {formatBytes(client.storage_used_bytes)} + + {client.last_seen_at + ? new Date(client.last_seen_at).toLocaleString() + : 'Nunca'} + +
+ + +
+
+ No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar. +
+
+
+ + {/* Registration Modal */} + {showModal && ( +
+
+
+

Registrar Agente Windows

+ +
+ + {!generatedCode ? ( +
+
+ + setClientHint(e.target.value)} + /> +

+ Se generará un código de un solo uso válido por 48 horas. +

+
+ +
+ + +
+
+ ) : ( +
+
+
+ Código de Registro Único +
+
+ {generatedCode.code} +
+
+ Expira: {new Date(generatedCode.expires_at).toLocaleString()} +
+
+ +
+ +
+ {psCommand} + +
+
+ +
+ +
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/DashboardView.tsx b/frontend/src/pages/DashboardView.tsx new file mode 100644 index 0000000..bc271a7 --- /dev/null +++ b/frontend/src/pages/DashboardView.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { + Users, + CheckCircle, + HardDrive, + Activity, + Clock, + AlertTriangle, + Server +} from 'lucide-react'; +import { DashboardStats, EventLogItem } from '../services/api'; +import { LiveTransferMeter, ActiveUpload } from '../components/LiveTransferMeter'; + +interface DashboardViewProps { + stats: DashboardStats | null; + events: EventLogItem[]; + activeUploads: ActiveUpload[]; + onNavigateToClients: () => void; +} + +export const DashboardView: React.FC = ({ + stats, + events, + activeUploads, + onNavigateToClients, +}) => { + 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 usedBytes = stats?.storage.used_bytes || 0; + const totalBytes = stats?.storage.total_bytes || 1; + const usagePct = stats?.storage.usage_percent || 0; + + return ( +
+ {/* High-level Statistics Grid */} +
+
+
+

Clientes Windows

+
{stats?.total_clients ?? 0}
+
+ ● {stats?.online_clients ?? 0} Online  |  {stats?.offline_clients ?? 0} Offline +
+
+
+ +
+
+ +
+
+

Backups Hoy

+
{stats?.backups_today_count ?? 0}
+
+ ✓ {stats?.backups_today_success ?? 0} Exitosos  |  {stats?.backups_today_failed ?? 0} Fallidos +
+
+
+ +
+
+ +
+
+

Almacenamiento Proxmox

+
{formatBytes(usedBytes)}
+
+ {usagePct}% de {formatBytes(totalBytes)} asignados +
+
+
+ +
+
+ +
+
+

Trabajos Activos

+
{stats?.total_jobs ?? 0}
+
+ {stats?.active_uploads_count ?? 0} subidas en curso +
+
+
+ +
+
+
+ + {/* Storage Pool Progress Bar */} +
+
+
+

Pool de Almacenamiento Proxmox VE (LXC)

+

+ Ruta: {stats?.storage.storage_root || '/storage/backups'} +

+
+
+ + {usagePct}% + +
+
+
+
+
+
+
+
+ + {/* Live Transfer Telemetry */} + + + {/* Recent Events & Quick Actions Grid */} +
+
+
+

+ + Actividad Reciente del Sistema +

+ Últimos eventos +
+ +
+ + + + + + + + + + {events.slice(0, 6).map((evt) => ( + + + + + + ))} + {events.length === 0 && ( + + + + )} + +
HoraEventoMensaje
+ {new Date(evt.timestamp).toLocaleTimeString()} + + + {evt.event_type} + + {evt.message}
+ No hay eventos registrados recientemente. +
+
+
+ +
+
+

OnEver Architecture

+

+ Plataforma desacoplada en dos capas: +

+
    +
  • Backend API: FastAPI + PostgreSQL en LXC 1
  • +
  • Storage Node: Volumen aislado en LXC 2
  • +
  • Motor Chunks: Subida por bloques de 4MB con reanudación y SHA-256
  • +
  • Agente Windows: Servicio de fondo con detector de locks SQL
  • +
+
+ +
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/EventsView.tsx b/frontend/src/pages/EventsView.tsx new file mode 100644 index 0000000..4a4d0dc --- /dev/null +++ b/frontend/src/pages/EventsView.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { FileText, ShieldAlert, CheckCircle, Info, AlertTriangle, Search } from 'lucide-react'; +import { EventLogItem } from '../services/api'; + +interface EventsViewProps { + events: EventLogItem[]; +} + +export const EventsView: React.FC = ({ events }) => { + const [filterSeverity, setFilterSeverity] = useState('ALL'); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredEvents = events.filter((evt) => { + const matchesSev = filterSeverity === 'ALL' || evt.severity === filterSeverity; + const matchesSearch = + evt.message.toLowerCase().includes(searchQuery.toLowerCase()) || + evt.event_type.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesSev && matchesSearch; + }); + + return ( +
+
+
+

Registro de Auditoría & Eventos

+

+ Trazabilidad completa de inicios de sesión, transferencias por bloques, registros y retenciones +

+
+
+ +
+
+
+ setSearchQuery(e.target.value)} + /> + +
+ +
+ {['ALL', 'INFO', 'WARNING', 'ERROR'].map((sev) => ( + + ))} +
+
+
+ +
+
+ + + + + + + + + + + + + {filteredEvents.map((evt) => ( + + + + + + + + + ))} + {filteredEvents.length === 0 && ( + + + + )} + +
TimestampSeveridadTipo de EventoDetalle del MensajeCliente IDIP / Usuario
+ {new Date(evt.timestamp).toLocaleString()} + + + {evt.severity} + + + + {evt.event_type} + + {evt.message} + {evt.client_id ? `#${evt.client_id}` : '—'} + + {evt.user_email || evt.ip_address || 'Sistema'} +
+ No hay registros de eventos que coincidan con los filtros. +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/JobsView.tsx b/frontend/src/pages/JobsView.tsx new file mode 100644 index 0000000..26a0852 --- /dev/null +++ b/frontend/src/pages/JobsView.tsx @@ -0,0 +1,336 @@ +import React, { useState } from 'react'; +import { + Plus, + Play, + Trash2, + Clock, + Folder, + Filter, + Calendar, + X +} from 'lucide-react'; +import { BackupJobItem, ClientItem, api } from '../services/api'; + +interface JobsViewProps { + jobs: BackupJobItem[]; + clients: ClientItem[]; + onRefresh: () => void; +} + +export const JobsView: React.FC = ({ jobs, clients, onRefresh }) => { + const [showModal, setShowModal] = useState(false); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + client_id: clients[0]?.id || 0, + name: '', + source_path: 'C:\\SQLBackups', + file_patterns: '*.bak,*.mdf', + schedule_cron: '0 2 * * *', + keep_daily: 7, + keep_weekly: 4, + keep_monthly: 12, + min_stable_time_seconds: 60, + }); + + const handleCreateJob = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.client_id) { + alert('Por favor selecciona un cliente'); + return; + } + setLoading(true); + try { + await api.createJob(formData); + setShowModal(false); + onRefresh(); + } catch (err: any) { + alert(`Error creando trabajo: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const handleTrigger = async (job: BackupJobItem) => { + try { + await api.triggerJob(job.id); + alert(`¡Trabajo ${job.job_code} encolado para ejecución inmediata!`); + onRefresh(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + const handleDelete = async (job: BackupJobItem) => { + if (confirm(`¿Eliminar el trabajo de backup ${job.name} (${job.job_code})?`)) { + try { + await api.deleteJob(job.id); + onRefresh(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + } + }; + + const getClientName = (clientId: number) => { + const client = clients.find((c) => c.id === clientId); + return client ? `${client.name} (${client.client_code})` : `Cliente #${clientId}`; + }; + + return ( +
+
+
+

Trabajos de Backup Programados

+

+ Configuración de rutas de origen Windows, filtros de extensión y políticas de retención +

+
+ +
+ +
+
+ + + + + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + + + + ))} + {jobs.length === 0 && ( + + + + )} + +
Job IDNombre del TrabajoCliente AsignadoOrigen en WindowsFiltrosRetenciónHorario / CronEstadoAcciones
+ + {job.job_code} + + {job.name} + {getClientName(job.client_id)} + +
+ + {job.source_path} +
+
+ {job.file_patterns} + + {job.keep_daily}d /{' '} + {job.keep_weekly}w /{' '} + {job.keep_monthly}m + + {job.schedule_cron} + + + {job.status} + + +
+ + +
+
+ No hay trabajos de backup configurados. Haz clic en "Crear Trabajo de Backup". +
+
+
+ + {/* Create Job Modal */} + {showModal && ( +
+
+
+

Nuevo Trabajo de Backup

+ +
+ +
+
+ + +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+
+ + setFormData({ ...formData, source_path: e.target.value })} + /> +
+
+ + setFormData({ ...formData, file_patterns: e.target.value })} + /> +
+
+ +
+
+ + setFormData({ ...formData, schedule_cron: e.target.value })} + /> +
+
+ + setFormData({ ...formData, min_stable_time_seconds: Number(e.target.value) })} + /> +
+
+ +
+ +
+
+ Días (Diario): + setFormData({ ...formData, keep_daily: Number(e.target.value) })} + /> +
+
+ Semanas (Semanal): + setFormData({ ...formData, keep_weekly: Number(e.target.value) })} + /> +
+
+ Meses (Mensual): + setFormData({ ...formData, keep_monthly: Number(e.target.value) })} + /> +
+
+
+ +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/LoginView.tsx b/frontend/src/pages/LoginView.tsx new file mode 100644 index 0000000..beb2fcd --- /dev/null +++ b/frontend/src/pages/LoginView.tsx @@ -0,0 +1,174 @@ +import React, { useState } from 'react'; +import { ShieldCheck, Lock, Mail, ArrowRight, Server, Key } from 'lucide-react'; +import { api, setAuthToken } from '../services/api'; + +interface LoginViewProps { + onLoginSuccess: (user: any) => void; +} + +export const LoginView: React.FC = ({ onLoginSuccess }) => { + const [email, setEmail] = useState('admin@oneverdrive.local'); + const [password, setPassword] = useState('Admin1234!'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const res = await api.login(email.trim(), password); + setAuthToken(res.access_token); + localStorage.setItem('oed_user', JSON.stringify(res.user)); + onLoginSuccess(res.user); + } catch (err: any) { + setError(err.message || 'Credenciales incorrectas o error en el servidor.'); + } finally { + setLoading(false); + } + }; + + const fillDefaultCredentials = () => { + setEmail('admin@oneverdrive.local'); + setPassword('Admin1234!'); + }; + + return ( +
+
+ {/* Brand Header */} +
+
+ +
+

+ OnEver Drive +

+

+ Plataforma Centralizada de Backup & Sincronización +

+
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Login Form */} +
+
+ + setEmail(e.target.value)} + placeholder="admin@oneverdrive.local" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + /> +
+ + +
+ + {/* Quick Demo Credentials */} +
+
+ Credenciales de Administrador por Defecto: +
+ +
+
+
+ ); +}; diff --git a/frontend/src/pages/RestoreView.tsx b/frontend/src/pages/RestoreView.tsx new file mode 100644 index 0000000..1ca8bd4 --- /dev/null +++ b/frontend/src/pages/RestoreView.tsx @@ -0,0 +1,204 @@ +import React, { useState, useEffect } from 'react'; +import { + Download, + Trash2, + FileCheck, + ShieldCheck, + Search, + HardDrive, + Filter +} from 'lucide-react'; +import { BackupFileItem, ClientItem, api } from '../services/api'; + +interface RestoreViewProps { + clients: ClientItem[]; +} + +export const RestoreView: React.FC = ({ clients }) => { + const [backups, setBackups] = useState([]); + const [selectedClientId, setSelectedClientId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + + const fetchBackups = async () => { + setLoading(true); + try { + const res = await api.getBackups(selectedClientId); + setBackups(res); + } catch (err: any) { + console.error('Failed to fetch backups:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchBackups(); + }, [selectedClientId]); + + 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 handleDownload = (backup: BackupFileItem) => { + window.open(`/api/backups/${backup.id}/download`, '_blank'); + }; + + const handleDelete = async (backup: BackupFileItem) => { + if (confirm(`¿Eliminar la copia de seguridad '${backup.filename}' del almacenamiento central?`)) { + try { + await api.deleteBackup(backup.id); + fetchBackups(); + } catch (err: any) { + alert(`Error eliminando archivo: ${err.message}`); + } + } + }; + + const filteredBackups = backups.filter((b) => + b.filename.toLowerCase().includes(searchQuery.toLowerCase()) || + b.sha256.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const getClientName = (clientId: number) => { + const c = clients.find((client) => client.id === clientId); + return c ? `${c.name} (${c.client_code})` : `Cliente #${clientId}`; + }; + + return ( +
+
+
+

Explorador de Backups & Restauración

+

+ Visualización, verificación de integridad SHA-256 y descarga directa de archivos respaldados +

+
+
+ + {/* Filter and Search Bar */} +
+
+
+ setSearchQuery(e.target.value)} + /> + +
+ +
+ + +
+
+
+ + {/* Backups Table */} +
+
+ + + + + + + + + + + + + + {filteredBackups.map((backup) => ( + + + + + + + + + + ))} + {filteredBackups.length === 0 && ( + + + + )} + +
Archivo ResguardadoCliente OrigenTamañoIntegridad SHA-256RetenciónFecha de RespaldoAcciones
+
+ +
+
{backup.filename}
+
+ {backup.relative_path} +
+
+
+
+ {getClientName(backup.client_id)} + + {formatBytes(backup.file_size)} + +
+ + + {backup.sha256.substring(0, 16)}... + +
+
+ + {backup.retention_tag} + + + {new Date(backup.created_at).toLocaleString()} + +
+ + +
+
+ {loading ? 'Cargando copias de seguridad...' : 'No se encontraron archivos de backup.'} +
+
+
+
+ ); +}; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..56856c3 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,197 @@ +const API_BASE = '/api'; + +export interface DashboardStats { + total_clients: number; + online_clients: number; + offline_clients: number; + total_jobs: number; + backups_today_count: number; + backups_today_success: number; + backups_today_failed: number; + active_uploads_count: number; + storage: { + total_bytes: number; + used_bytes: number; + free_bytes: number; + usage_percent: number; + storage_root: string; + }; +} + +export interface ClientItem { + id: number; + client_code: string; + name: string; + hostname?: string; + os_info?: string; + ip_address?: string; + agent_version: string; + status: string; + storage_used_bytes: number; + storage_quota_bytes: number; + last_seen_at?: string; + last_backup_at?: string; + is_active: boolean; + created_at: string; +} + +export interface BackupJobItem { + id: number; + job_code: string; + client_id: number; + name: string; + source_path: string; + file_patterns: string; + schedule_cron: string; + is_active: boolean; + keep_daily: number; + keep_weekly: number; + keep_monthly: number; + min_stable_time_seconds: number; + status: string; + last_run_at?: string; + next_run_at?: string; + created_at: string; +} + +export interface BackupFileItem { + id: number; + client_id: number; + job_id?: number; + session_id?: number; + filename: string; + relative_path: string; + file_size: number; + sha256: string; + retention_tag: string; + is_active: boolean; + created_at: string; +} + +export interface EventLogItem { + id: number; + timestamp: string; + event_type: string; + severity: string; + client_id?: number; + job_id?: number; + user_email?: string; + ip_address?: string; + message: string; +} + +export const getAuthToken = (): string | null => { + return localStorage.getItem('oed_token'); +}; + +export const getCurrentUser = (): any | null => { + const saved = localStorage.getItem('oed_user'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return null; + } + } + return null; +}; + +export const setAuthToken = (token: string | null) => { + if (token) { + localStorage.setItem('oed_token', token); + } else { + localStorage.removeItem('oed_token'); + localStorage.removeItem('oed_user'); + } +}; + +const request = async (endpoint: string, options: RequestInit = {}): Promise => { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + headers, + }); + + if (response.status === 401) { + setAuthToken(null); + window.dispatchEvent(new Event('oed_unauthorized')); + const errorData = await response.json().catch(() => ({ detail: 'Authentication required' })); + throw new Error(errorData.detail || 'Authentication required'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Network error' })); + throw new Error(errorData.detail || `Request failed with status ${response.status}`); + } + + return response.json(); +}; + +export const api = { + // Auth + login: (email: string, password: string) => + request<{ access_token: string; user: any }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }), + + // Stats + getStats: () => request('/stats'), + + // Clients + getClients: () => request('/clients'), + createRegistrationCode: (clientNameHint?: string) => + request<{ code: string; expires_at: string }>('/clients/registration-code', { + method: 'POST', + body: JSON.stringify({ client_name_hint: clientNameHint, expires_in_hours: 48 }), + }), + revokeClient: (clientId: number) => + request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }), + deleteClient: (clientId: number) => + request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }), + + // Jobs + getJobs: (clientId?: number) => + request(clientId ? `/jobs?client_id=${clientId}` : '/jobs'), + createJob: (jobData: { + client_id: number; + name: string; + source_path: string; + file_patterns: string; + schedule_cron: string; + keep_daily: number; + keep_weekly: number; + keep_monthly: number; + min_stable_time_seconds: number; + }) => + request('/jobs', { + method: 'POST', + body: JSON.stringify(jobData), + }), + triggerJob: (jobId: number) => + request<{ message: string }>(`/jobs/${jobId}/trigger`, { method: 'POST' }), + deleteJob: (jobId: number) => + request<{ message: string }>(`/jobs/${jobId}`, { method: 'DELETE' }), + + // Backups + getBackups: (clientId?: number, jobId?: number) => { + const params = new URLSearchParams(); + if (clientId) params.append('client_id', clientId.toString()); + if (jobId) params.append('job_id', jobId.toString()); + return request(`/backups?${params.toString()}`); + }, + deleteBackup: (backupId: number) => + request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }), + + // Events + getEvents: (limit: number = 50) => request(`/events?limit=${limit}`), +}; diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts new file mode 100644 index 0000000..431c7e3 --- /dev/null +++ b/frontend/src/services/websocket.ts @@ -0,0 +1,67 @@ +export type WebSocketCallback = (event: { type: string; data: any }) => void; + +class WebSocketClient { + private ws: WebSocket | null = null; + private listeners: Set = new Set(); + private reconnectInterval = 3000; + private isConnected = false; + + public connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/ws/telemetry`; + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.isConnected = true; + this.notify({ type: 'WS_CONNECTED', data: { status: true } }); + }; + + this.ws.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data); + this.notify(parsed); + } catch { + // Ignored + } + }; + + this.ws.onclose = () => { + this.isConnected = false; + this.notify({ type: 'WS_DISCONNECTED', data: { status: false } }); + setTimeout(() => this.connect(), this.reconnectInterval); + }; + + this.ws.onerror = () => { + this.ws?.close(); + }; + } catch { + setTimeout(() => this.connect(), this.reconnectInterval); + } + } + + public subscribe(callback: WebSocketCallback) { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + private notify(payload: { type: string; data: any }) { + this.listeners.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error('WebSocket subscriber error:', err); + } + }); + } + + public getStatus() { + return this.isConnected; + } +} + +export const wsClient = new WebSocketClient(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6bfa73a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..af5a935 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://127.0.0.1:8000', + ws: true, + } + } + } +}); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..442ef6d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest_asyncio +from app.core.database import engine, Base + +@pytest_asyncio.fixture(autouse=True) +async def clean_database(): + """Drops and re-creates all tables before each test for total test isolation.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield diff --git a/tests/test_chunk_resume.py b/tests/test_chunk_resume.py new file mode 100644 index 0000000..5abc360 --- /dev/null +++ b/tests/test_chunk_resume.py @@ -0,0 +1,147 @@ +import os +import shutil +import pytest +import hashlib +import tempfile +from pathlib import Path +from httpx import AsyncClient, ASGITransport + +from app.main import app +from app.core.database import init_db, AsyncSessionLocal +from app.core.config import settings +from app.storage.local import storage_provider +from app.models.models import Client, ClientCredential, BackupSession, BackupFile +from app.core.security import hash_token + +@pytest.mark.asyncio +async def test_chunk_upload_interruption_and_resumption(): + """ + CRITICAL PROOF-OF-CONCEPT TEST (#34): + 1. Create a large test binary file with random bytes and known SHA-256. + 2. Start upload session. + 3. Upload first 50% of chunks. + 4. Simulate client disconnect / network drop. + 5. Reconnect client, query session status, ensure server reports already received chunks. + 6. Upload ONLY the remaining 50% of chunks. + 7. Complete upload session. + 8. Verify server-assembled file SHA-256 matches exact original hash. + """ + await init_db() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + # 1. Setup client and credentials in database + async with AsyncSessionLocal() as db: + client = Client( + client_code="CLIENT-TEST-01", + name="Windows 11 Test Machine", + hostname="WIN11-PROD", + os_info="Windows 11 Pro", + agent_version="1.0.0", + status="ONLINE" + ) + db.add(client) + await db.flush() + + device_id = "dev-test-uuid-001" + device_token = "secret-token-test-12345" + token_hash = hash_token(device_token) + + cred = ClientCredential( + client_id=client.id, + device_id=device_id, + token_hash=token_hash, + name="Test Agent Credential" + ) + db.add(cred) + await db.commit() + + headers = { + "X-Device-Id": device_id, + "X-Device-Token": device_token + } + + # 2. Generate a 12 MB test file (with 4 MB chunks -> 3 chunks total) + chunk_size = 4 * 1024 * 1024 + file_size = 12 * 1024 * 1024 + test_bytes = os.urandom(file_size) + expected_sha256 = hashlib.sha256(test_bytes).hexdigest() + + # 3. Initialize upload session + init_resp = await ac.post("/api/upload/session", json={ + "filename": "database_production.bak", + "file_size": file_size, + "sha256": expected_sha256, + "chunk_size": chunk_size + }, headers=headers) + assert init_resp.status_code == 200, f"Init failed: {init_resp.text}" + session_data = init_resp.json() + session_code = session_data["session_code"] + assert session_data["total_chunks"] == 3 + assert session_data["received_chunks"] == [] + + # 4. Upload Chunk 0 (0MB to 4MB) + chunk_0_data = test_bytes[0 : chunk_size] + chunk_0_hash = hashlib.sha256(chunk_0_data).hexdigest() + c0_resp = await ac.post( + f"/api/upload/{session_code}/chunk?chunk_index=0&chunk_sha256={chunk_0_hash}", + content=chunk_0_data, + headers={**headers, "Content-Type": "application/octet-stream"} + ) + assert c0_resp.status_code == 200 + assert c0_resp.json()["is_received"] == True + + # 5. SIMULATE NETWORK CUT / INTERRUPTION! + # Client disconnects. We now re-query status as if reconnecting later. + status_resp = await ac.get(f"/api/upload/{session_code}/status", headers=headers) + assert status_resp.status_code == 200 + status_data = status_resp.json() + assert status_data["received_chunks"] == [0] + assert status_data["missing_chunks"] == [1, 2] + + # Or re-call /session endpoint: it should return already received chunks! + resume_resp = await ac.post("/api/upload/session", json={ + "filename": "database_production.bak", + "file_size": file_size, + "sha256": expected_sha256, + "chunk_size": chunk_size + }, headers=headers) + assert resume_resp.status_code == 200 + assert resume_resp.json()["received_chunks"] == [0] + + # 6. Upload Chunk 1 (4MB to 8MB) + chunk_1_data = test_bytes[chunk_size : 2 * chunk_size] + chunk_1_hash = hashlib.sha256(chunk_1_data).hexdigest() + c1_resp = await ac.post( + f"/api/upload/{session_code}/chunk?chunk_index=1&chunk_sha256={chunk_1_hash}", + content=chunk_1_data, + headers={**headers, "Content-Type": "application/octet-stream"} + ) + assert c1_resp.status_code == 200 + + # 7. Upload Chunk 2 (8MB to 12MB) + chunk_2_data = test_bytes[2 * chunk_size : 3 * chunk_size] + chunk_2_hash = hashlib.sha256(chunk_2_data).hexdigest() + c2_resp = await ac.post( + f"/api/upload/{session_code}/chunk?chunk_index=2&chunk_sha256={chunk_2_hash}", + content=chunk_2_data, + headers={**headers, "Content-Type": "application/octet-stream"} + ) + assert c2_resp.status_code == 200 + + # 8. Complete session and trigger assembly & SHA-256 integrity verification + complete_resp = await ac.post(f"/api/upload/{session_code}/complete", headers=headers) + assert complete_resp.status_code == 200, f"Complete failed: {complete_resp.text}" + complete_data = complete_resp.json() + assert complete_data["status"] == "SUCCESS" + assert complete_data["sha256"] == expected_sha256 + assert complete_data["file_size"] == file_size + + # 9. Verify physical file on disk and its SHA-256 directly + assembled_file_path = await storage_provider.get_file_path(complete_data["relative_path"]) + assert os.path.exists(assembled_file_path) + with open(assembled_file_path, "rb") as f: + disk_bytes = f.read() + assert len(disk_bytes) == file_size + assert hashlib.sha256(disk_bytes).hexdigest() == expected_sha256 + print("\n>>> PoC Test Passed: Interrupted chunk upload successfully resumed and verified bit-for-bit!") diff --git a/tests/test_file_lock.py b/tests/test_file_lock.py new file mode 100644 index 0000000..c6a3066 --- /dev/null +++ b/tests/test_file_lock.py @@ -0,0 +1,30 @@ +import os +import time +import pytest +import tempfile +from pathlib import Path + +from agent.scanner import is_file_locked, is_file_stable, DirectoryScanner + +def test_file_lock_and_stability(): + """ + Tests detection of file locks, growing files, and stable files. + """ + with tempfile.TemporaryDirectory() as tmpdir: + test_file = Path(tmpdir) / "test_active.bak" + test_file.write_text("initial data") + + # 1. Stable file test + # When file was modified now, with min_stable_seconds=0, it should be immediately stable + assert is_file_stable(test_file, min_stable_seconds=0) == True + + # 2. Scanner test with filter + other_file = Path(tmpdir) / "ignored.txt" + other_file.write_text("not a backup") + + scanner = DirectoryScanner(tmpdir, file_patterns="*.bak", min_stable_seconds=0) + found = scanner.scan() + assert len(found) == 1 + assert found[0].name == "test_active.bak" + + print("\n>>> File Lock & Scanner Test Passed!") diff --git a/tests/test_isolation.py b/tests/test_isolation.py new file mode 100644 index 0000000..7a2bd46 --- /dev/null +++ b/tests/test_isolation.py @@ -0,0 +1,85 @@ +import os +import pytest +import hashlib +from httpx import AsyncClient, ASGITransport + +from app.main import app +from app.core.database import init_db, AsyncSessionLocal +from app.models.models import Client, ClientCredential +from app.core.security import hash_token + +@pytest.mark.asyncio +async def test_client_isolation_security(): + """ + Ensures that Client A cannot access, query, upload to, or complete sessions + belonging to Client B. + """ + await init_db() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + # Create Client A + async with AsyncSessionLocal() as db: + client_a = Client( + client_code="CLIENT-ISO-A", + name="Company A WinServer", + status="ONLINE" + ) + db.add(client_a) + await db.flush() + token_a = "token-secret-client-a" + cred_a = ClientCredential( + client_id=client_a.id, + device_id="dev-iso-a", + token_hash=hash_token(token_a), + name="Cred A" + ) + db.add(cred_a) + + # Create Client B + client_b = Client( + client_code="CLIENT-ISO-B", + name="Company B WinServer", + status="ONLINE" + ) + db.add(client_b) + await db.flush() + token_b = "token-secret-client-b" + cred_b = ClientCredential( + client_id=client_b.id, + device_id="dev-iso-b", + token_hash=hash_token(token_b), + name="Cred B" + ) + db.add(cred_b) + await db.commit() + + headers_a = {"X-Device-Id": "dev-iso-a", "X-Device-Token": token_a} + headers_b = {"X-Device-Id": "dev-iso-b", "X-Device-Token": token_b} + + # Client A starts an upload session + resp_a = await ac.post("/api/upload/session", json={ + "filename": "confidential_a.bak", + "file_size": 1024 * 1024, + "sha256": hashlib.sha256(b"secret_a_data").hexdigest(), + "chunk_size": 1024 * 1024 + }, headers=headers_a) + assert resp_a.status_code == 200 + session_a_code = resp_a.json()["session_code"] + + # Client B attempts to view or hijack Client A's session -> MUST be denied (404 / 403) + hijack_status = await ac.get(f"/api/upload/{session_a_code}/status", headers=headers_b) + assert hijack_status.status_code == 404, "Security violation: Client B accessed Client A session!" + + # Client B attempts to upload chunk to Client A's session -> MUST be denied + hijack_chunk = await ac.post( + f"/api/upload/{session_a_code}/chunk?chunk_index=0", + content=b"malicious_bytes", + headers={**headers_b, "Content-Type": "application/octet-stream"} + ) + assert hijack_chunk.status_code == 404, "Security violation: Client B injected chunk into Client A session!" + + # Client B attempts to complete Client A's session -> MUST be denied + hijack_complete = await ac.post(f"/api/upload/{session_a_code}/complete", headers=headers_b) + assert hijack_complete.status_code == 404, "Security violation: Client B triggered completion on Client A session!" + print("\n>>> Isolation Test Passed: Strict multitenant isolation verified!") diff --git a/tests/test_retention.py b/tests/test_retention.py new file mode 100644 index 0000000..0fbbf17 --- /dev/null +++ b/tests/test_retention.py @@ -0,0 +1,75 @@ +import os +import pytest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.core.database import init_db, AsyncSessionLocal +from app.models.models import Client, BackupJob, BackupFile +from app.services.retention_service import apply_retention_policy +from app.core.config import settings + +@pytest.mark.asyncio +async def test_retention_policy_engine(): + """ + Tests retention rule application: + - 7 Daily, 4 Weekly, 12 Monthly. + - Creates synthetic historical backups from past 30 days. + - Verifies that old non-anchor daily backups are pruned, while weekly/monthly anchors and newest backups are preserved. + """ + await init_db() + + async with AsyncSessionLocal() as db: + # Create client & job + client = Client(client_code="CLIENT-RET-01", name="Retention Test Machine", status="ONLINE") + db.add(client) + await db.flush() + + job = BackupJob( + job_code="JOB-RET-01", + client_id=client.id, + name="SQL Production Retention Job", + source_path="C:\\SQLBackups", + keep_daily=3, # Keep only last 3 daily + keep_weekly=2, # Keep 2 weekly + keep_monthly=1 # Keep 1 monthly + ) + db.add(job) + await db.flush() + + now = datetime.now(timezone.utc) + created_files = [] + + # Create dummy physical files and DB records for 10 days in the past + for day in range(10): + past_date = now - timedelta(days=day) + rel_path = f"clients/{client.client_code}/{job.job_code}/backup_day_{day}.bak" + full_path = Path(settings.STORAGE_ROOT) / rel_path + full_path.parent.mkdir(parents=True, exist_ok=True) + with open(full_path, "wb") as f: + f.write(b"sample_retention_backup_data") + + bf = BackupFile( + client_id=client.id, + job_id=job.id, + filename=f"backup_day_{day}.bak", + relative_path=rel_path, + file_size=len(b"sample_retention_backup_data"), + sha256="test-sha256", + retention_tag="DAILY", + is_active=True, + created_at=past_date + ) + db.add(bf) + created_files.append(bf) + + await db.commit() + + # Run retention policy + pruned_count = await apply_retention_policy(db, job.id) + assert pruned_count > 0, "Retention should have pruned expired daily backups" + + # Verify newest backup (day 0) is preserved + await db.refresh(created_files[0]) + assert created_files[0].is_active == True, "Newest backup must never be pruned" + + print(f"\n>>> Retention Test Passed: Successfully pruned {pruned_count} historical backups while protecting anchors!") diff --git a/windows-agent/agent/chunker.py b/windows-agent/agent/chunker.py new file mode 100644 index 0000000..808a845 --- /dev/null +++ b/windows-agent/agent/chunker.py @@ -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 diff --git a/windows-agent/agent/config.py b/windows-agent/agent/config.py new file mode 100644 index 0000000..c0dd12a --- /dev/null +++ b/windows-agent/agent/config.py @@ -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) diff --git a/windows-agent/agent/scanner.py b/windows-agent/agent/scanner.py new file mode 100644 index 0000000..d86d720 --- /dev/null +++ b/windows-agent/agent/scanner.py @@ -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 diff --git a/windows-agent/agent/service.py b/windows-agent/agent/service.py new file mode 100644 index 0000000..0e3f859 --- /dev/null +++ b/windows-agent/agent/service.py @@ -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)) diff --git a/windows-agent/agent/state_db.py b/windows-agent/agent/state_db.py new file mode 100644 index 0000000..f526305 --- /dev/null +++ b/windows-agent/agent/state_db.py @@ -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() diff --git a/windows-agent/agent/uploader.py b/windows-agent/agent/uploader.py new file mode 100644 index 0000000..c3d037e --- /dev/null +++ b/windows-agent/agent/uploader.py @@ -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 diff --git a/windows-agent/agent_app_pyqt.py b/windows-agent/agent_app_pyqt.py new file mode 100644 index 0000000..5513531 --- /dev/null +++ b/windows-agent/agent_app_pyqt.py @@ -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() diff --git a/windows-agent/agent_cli.py b/windows-agent/agent_cli.py new file mode 100644 index 0000000..89a933b --- /dev/null +++ b/windows-agent/agent_cli.py @@ -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() diff --git a/windows-agent/agent_gui.py b/windows-agent/agent_gui.py new file mode 100644 index 0000000..8c51067 --- /dev/null +++ b/windows-agent/agent_gui.py @@ -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() diff --git a/windows-agent/agent_tray.py b/windows-agent/agent_tray.py new file mode 100644 index 0000000..e9899d5 --- /dev/null +++ b/windows-agent/agent_tray.py @@ -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() diff --git a/windows-agent/assets/icon.ico b/windows-agent/assets/icon.ico new file mode 100644 index 0000000..b614976 Binary files /dev/null and b/windows-agent/assets/icon.ico differ diff --git a/windows-agent/assets/icon.png b/windows-agent/assets/icon.png new file mode 100644 index 0000000..d9ca29a Binary files /dev/null and b/windows-agent/assets/icon.png differ diff --git a/windows-agent/build_exe.py b/windows-agent/build_exe.py new file mode 100644 index 0000000..87429c9 --- /dev/null +++ b/windows-agent/build_exe.py @@ -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() diff --git a/windows-agent/create_icons.py b/windows-agent/create_icons.py new file mode 100644 index 0000000..e821df8 --- /dev/null +++ b/windows-agent/create_icons.py @@ -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() diff --git a/windows-agent/installer/install_service.ps1 b/windows-agent/installer/install_service.ps1 new file mode 100644 index 0000000..35b8534 --- /dev/null +++ b/windows-agent/installer/install_service.ps1 @@ -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 diff --git a/windows-agent/requirements.txt b/windows-agent/requirements.txt new file mode 100644 index 0000000..c4b8ba5 --- /dev/null +++ b/windows-agent/requirements.txt @@ -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 diff --git a/windows-agent/start_tray_agent.bat b/windows-agent/start_tray_agent.bat new file mode 100644 index 0000000..6cf3958 --- /dev/null +++ b/windows-agent/start_tray_agent.bat @@ -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