feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+45
View File
@@ -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
+83
View File
@@ -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`.
+72
View File
@@ -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!"}
+116
View File
@@ -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"}
+245
View File
@@ -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"}
+87
View File
@@ -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
+32
View File
@@ -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()
+175
View File
@@ -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"}
+81
View File
@@ -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
}
+153
View File
@@ -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)}"
)
+44
View File
@@ -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)
+34
View File
@@ -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)
+57
View File
@@ -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()
+88
View File
@@ -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
}
+174
View File
@@ -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)
+207
View File
@@ -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
+48
View File
@@ -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
+110
View File
@@ -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
+277
View File
@@ -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
+64
View File
@@ -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
+168
View File
@@ -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()
+36
View File
@@ -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()
+16
View File
@@ -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
+127
View File
@@ -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);
+56
View File
@@ -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;
}
}
}
@@ -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 <REPO_URL> /opt/onever_drive"
echo " bash /opt/onever_drive/deployment/proxmox/02-install-backend-debian.sh"
echo ""
@@ -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 <<EOF > /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 ""
+87
View File
@@ -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 <midominio.com> # 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
@@ -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"
+64
View File
@@ -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 <<EOF > /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"
+32
View File
@@ -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 <<EOF
--------------------------------------------------------------------------------
Para vincular este almacenamiento con el LXC 1 (Backend) en el nodo Proxmox Host:
Ejecute en el shell del nodo Proxmox VE:
pct set 100 -mp0 /storage/backups,mp=/storage/backups
pct set 100 -mp1 /storage/temp,mp=/storage/temp
--------------------------------------------------------------------------------
EOF
echo "=== [4/4] Storage Node configurado correctamente ==="
+143
View File
@@ -0,0 +1,143 @@
# OnEver Drive — Referencia de API REST & WebSockets
Base URL: `/api`
---
## 1. Autenticación
### `POST /api/auth/login`
Inicia sesión de usuario administrativo.
- **Body**:
```json
{
"email": "admin@oneverdrive.local",
"password": "Admin1234!"
}
```
- **Response**:
```json
{
"access_token": "eyJhbGciOi...",
"token_type": "bearer",
"user": { "id": 1, "email": "admin@oneverdrive.local", "role": "ADMIN" }
}
```
---
## 2. Clientes Windows
### `POST /api/clients/registration-code`
Genera un código temporal de un solo uso para registrar un nuevo agente Windows.
- **Auth**: Bearer Token (ADMIN)
- **Body**: `{ "client_name_hint": "SQL Server Prod", "expires_in_hours": 48 }`
- **Response**: `{ "code": "OED-4A2F-9B1C", "expires_at": "2026-08-15T12:00:00Z" }`
### `POST /api/clients/register`
Invocado por el agente Windows con el código de registro para obtener credenciales únicas.
- **Body**:
```json
{
"registration_code": "OED-4A2F-9B1C",
"name": "SQL Server Prod",
"hostname": "WIN-SRV-2022",
"os_info": "Windows Server 2022 Datacenter",
"agent_version": "1.0.0"
}
```
- **Response**:
```json
{
"client_code": "CLIENT-0001",
"device_id": "8f3b4d7c-3b1a-4d2e-9c1a-8f3b4d7c3b1a",
"device_token": "oed_sec_a8b9c0d1...",
"name": "SQL Server Prod",
"server_time": "2026-08-13T16:00:00Z"
}
```
### `GET /api/clients`
Lista todos los clientes registrados.
### `POST /api/clients/{id}/revoke`
Revoca inmediatamente las credenciales de un cliente Windows.
---
## 3. Motor de Transferencia por Chunks
### `POST /api/upload/session`
Inicia una nueva sesión de subida o reanuda una sesión existente incompleta.
- **Headers**:
`X-Device-Id: <DEVICE_ID>`
`X-Device-Token: <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: <DEVICE_ID>`
`X-Device-Token: <DEVICE_TOKEN>`
`X-Chunk-Index: 1`
`X-Chunk-SHA256: <CHUNK_HASH>`
- **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" }`
+97
View File
@@ -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.
+111
View File
@@ -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 <URL_REPOSITORIO> /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 <URL_REPOSITORIO> /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
```
+84
View File
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnEver Drive — Centralized Enterprise Backup & Sync</title>
<meta name="description" content="Plataforma centralizada de backup y sincronización para Windows sobre Proxmox VE con transferencia por bloques, reanudación e integridad SHA-256." />
<!-- Google Fonts: Outfit & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1869
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+212
View File
@@ -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<any | null>(getCurrentUser());
const [authToken, setTokenState] = useState<string | null>(getAuthToken());
const [currentTab, setCurrentTab] = useState<string>('dashboard');
const [isWsConnected, setIsWsConnected] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [clients, setClients] = useState<ClientItem[]>([]);
const [jobs, setJobs] = useState<BackupJobItem[]>([]);
const [events, setEvents] = useState<EventLogItem[]>([]);
const [activeUploads, setActiveUploads] = useState<ActiveUpload[]>([]);
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 <LoginView onLoginSuccess={handleLoginSuccess} />;
}
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 (
<div className="app-layout">
<Sidebar
currentTab={currentTab}
setCurrentTab={setCurrentTab}
isWsConnected={isWsConnected}
/>
<div className="main-wrapper">
<Topbar
title={getTabTitle()}
user={currentUser}
onRefresh={loadData}
onLogout={handleLogout}
isLoading={isLoading}
/>
<main className="content-scrollable">
{currentTab === 'dashboard' && (
<DashboardView
stats={stats}
events={events}
activeUploads={activeUploads}
onNavigateToClients={() => setCurrentTab('clients')}
/>
)}
{currentTab === 'clients' && (
<ClientsView
clients={clients}
onRefresh={loadData}
/>
)}
{currentTab === 'jobs' && (
<JobsView
jobs={jobs}
clients={clients}
onRefresh={loadData}
/>
)}
{currentTab === 'restore' && (
<RestoreView
clients={clients}
/>
)}
{currentTab === 'events' && (
<EventsView
events={events}
/>
)}
</main>
</div>
</div>
);
};
@@ -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<LiveTransferMeterProps> = ({ uploads }) => {
if (uploads.length === 0) {
return (
<div className="glass-card" style={{ marginBottom: '28px', textAlign: 'center', padding: '32px 20px' }}>
<UploadCloud size={32} color="var(--text-dim)" style={{ margin: '0 auto 10px auto' }} />
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>Sin transferencias activas</h4>
<p style={{ fontSize: '0.8rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Los agentes Windows transmitirán bloques de archivos automáticamente según sus trabajos programados.
</p>
</div>
);
}
return (
<div className="glass-card" style={{ marginBottom: '28px', border: '1px solid rgba(6, 182, 212, 0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div className="pulse-dot" />
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Transferencias en Vivo ({uploads.length})</h3>
</div>
<span className="badge badge-syncing">Motor de Chunks Activo</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{uploads.map((upload) => {
const isComplete = upload.progress_percent >= 100;
return (
<div
key={upload.session_code}
style={{
background: 'rgba(0,0,0,0.3)',
padding: '16px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<div>
<span style={{ fontWeight: 600, fontSize: '0.92rem', color: '#fff' }}>
{upload.filename}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginLeft: '12px' }}>
Cliente #{upload.client_id}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--accent-cyan)', fontWeight: 600 }}>
{upload.received_chunks} / {upload.total_chunks} Chunks ({upload.progress_percent}%)
</span>
{isComplete && <CheckCircle2 size={16} color="var(--accent-emerald)" />}
</div>
</div>
<div className="progress-track" style={{ height: '10px' }}>
<div
className={`progress-fill ${isComplete ? '' : 'animated'}`}
style={{ width: `${Math.min(100, upload.progress_percent)}%` }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.74rem', color: 'var(--text-dim)', marginTop: '6px' }}>
<span>Sesión: {upload.session_code.substring(0, 18)}...</span>
<span>{isComplete ? 'Ensamblando & Verificando SHA-256...' : 'Transmitiendo bloques de 4 MB'}</span>
</div>
</div>
);
})}
</div>
</div>
);
};
+67
View File
@@ -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<SidebarProps> = ({ 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 (
<aside className="sidebar">
<div className="brand">
<div className="brand-icon">
<ShieldCheck size={24} />
</div>
<div className="brand-text">
<h1>OnEver Drive</h1>
<span>Proxmox Edition</span>
</div>
</div>
<nav className="nav-links">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = currentTab === item.id;
return (
<button
key={item.id}
className={`nav-btn ${isActive ? 'active' : ''}`}
onClick={() => setCurrentTab(item.id)}
>
<Icon size={18} />
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="sidebar-footer">
<div className="ws-status-badge">
<div className={`pulse-dot ${isWsConnected ? '' : 'offline'}`} />
<span>{isWsConnected ? 'Telemetría en Vivo' : 'Reconectando WS...'}</span>
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-dim)', textAlign: 'center', marginTop: '4px' }}>
v1.0.0 Proxmox LXC
</div>
</div>
</aside>
);
};
+55
View File
@@ -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<TopbarProps> = ({ title, user, onRefresh, onLogout, isLoading }) => {
return (
<header className="topbar">
<div className="topbar-title">
<h2>{title}</h2>
</div>
<div className="topbar-actions">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.84rem', color: 'var(--text-muted)' }}>
<Server size={16} color="var(--accent-cyan)" />
<span>LXC Proxmox Node</span>
</div>
<button
className="btn btn-secondary"
onClick={onRefresh}
disabled={isLoading}
style={{ padding: '8px 14px' }}
title="Actualizar datos"
>
<RefreshCw size={15} className={isLoading ? 'spin-anim' : ''} />
<span>Refrescar</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 12px', background: 'rgba(255,255,255,0.05)', borderRadius: 'var(--radius-sm)' }}>
<UserIcon size={16} color="var(--accent-indigo)" />
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>
{user?.email || 'admin@oneverdrive.local'}
</span>
</div>
<button
className="btn btn-danger"
onClick={onLogout}
style={{ padding: '8px 12px', fontSize: '0.82rem' }}
title="Cerrar sesión"
>
<LogOut size={15} />
<span>Salir</span>
</button>
</div>
</header>
);
};
+569
View File
@@ -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;
}
+10
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
+288
View File
@@ -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<ClientsViewProps> = ({ 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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Clientes Windows Registrados</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Administración centralizada de agentes Windows 10, 11 y Windows Server
</p>
</div>
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
<Plus size={16} />
Registrar Nuevo Cliente
</button>
</div>
<div className="glass-card">
<div className="table-container">
<table className="modern-table">
<thead>
<tr>
<th>Cliente ID</th>
<th>Nombre / Hostname</th>
<th>Sistema Operativo</th>
<th>Dirección IP</th>
<th>Estado</th>
<th>Espacio Utilizado</th>
<th>Última Conexión</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{clients.map((client) => {
const isOnline = client.status === 'ONLINE';
return (
<tr key={client.id}>
<td>
<span className="hash-badge" style={{ color: 'var(--accent-cyan)' }}>
{client.client_code}
</span>
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{client.os_info?.includes('Server') ? (
<ServerIcon size={16} color="var(--accent-indigo)" />
) : (
<Laptop size={16} color="var(--text-muted)" />
)}
<div>
<div style={{ fontWeight: 600 }}>{client.name}</div>
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
{client.hostname || 'Desconocido'} v{client.agent_version}
</div>
</div>
</div>
</td>
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
{client.os_info || 'Windows'}
</td>
<td style={{ fontSize: '0.84rem', fontFamily: 'var(--font-mono)' }}>
{client.ip_address || '—'}
</td>
<td>
<span className={`badge ${isOnline ? 'badge-online' : 'badge-offline'}`}>
{client.status}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.84rem' }}>
{formatBytes(client.storage_used_bytes)}
</td>
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
{client.last_seen_at
? new Date(client.last_seen_at).toLocaleString()
: 'Nunca'}
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleRevoke(client)}
title="Revocar credenciales"
>
<ShieldAlert size={14} color="var(--accent-amber)" />
Revocar
</button>
<button
className="btn btn-danger"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleDelete(client)}
title="Eliminar cliente"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
);
})}
{clients.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Registration Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-card">
<div className="modal-header">
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Registrar Agente Windows</h3>
<button
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
onClick={() => setShowModal(false)}
>
<X size={20} />
</button>
</div>
{!generatedCode ? (
<form onSubmit={handleGenerateCode}>
<div className="form-group">
<label>Nombre identificador del equipo (Opcional):</label>
<input
type="text"
className="form-input"
placeholder="Ej: Servidor SQL Producción"
value={clientHint}
onChange={(e) => setClientHint(e.target.value)}
/>
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Se generará un código de un solo uso válido por 48 horas.
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Generando...' : 'Generar Código de Registro'}
</button>
</div>
</form>
) : (
<div>
<div style={{ textAlign: 'center', margin: '16px 0 24px 0' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Código de Registro Único
</div>
<div
style={{
fontSize: '2rem',
fontWeight: 800,
color: 'var(--accent-cyan)',
fontFamily: 'var(--font-mono)',
letterSpacing: '0.1em',
marginTop: '6px',
}}
>
{generatedCode.code}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Expira: {new Date(generatedCode.expires_at).toLocaleString()}
</div>
</div>
<div className="form-group">
<label>Comando de instalación en Windows (PowerShell / CMD):</label>
<div className="code-box">
<span>{psCommand}</span>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.75rem' }}
onClick={() => copyToClipboard(psCommand)}
>
{isCopied ? <Check size={14} color="var(--accent-emerald)" /> : <Copy size={14} />}
{isCopied ? 'Copiado' : 'Copiar'}
</button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '24px' }}>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setShowModal(false);
onRefresh();
}}
>
Listo
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
+194
View File
@@ -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<DashboardViewProps> = ({
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 (
<div>
{/* High-level Statistics Grid */}
<div className="stats-grid">
<div className="glass-card stat-card">
<div className="stat-info">
<h3>Clientes Windows</h3>
<div className="stat-number">{stats?.total_clients ?? 0}</div>
<div className="stat-sub" style={{ color: 'var(--accent-emerald)' }}>
{stats?.online_clients ?? 0} Online &nbsp;|&nbsp; {stats?.offline_clients ?? 0} Offline
</div>
</div>
<div className="stat-icon">
<Users size={22} />
</div>
</div>
<div className="glass-card stat-card emerald">
<div className="stat-info">
<h3>Backups Hoy</h3>
<div className="stat-number">{stats?.backups_today_count ?? 0}</div>
<div className="stat-sub" style={{ color: '#34d399' }}>
{stats?.backups_today_success ?? 0} Exitosos &nbsp;|&nbsp; {stats?.backups_today_failed ?? 0} Fallidos
</div>
</div>
<div className="stat-icon" style={{ color: 'var(--accent-emerald)' }}>
<CheckCircle size={22} />
</div>
</div>
<div className="glass-card stat-card indigo">
<div className="stat-info">
<h3>Almacenamiento Proxmox</h3>
<div className="stat-number">{formatBytes(usedBytes)}</div>
<div className="stat-sub">
{usagePct}% de {formatBytes(totalBytes)} asignados
</div>
</div>
<div className="stat-icon" style={{ color: 'var(--accent-indigo)' }}>
<HardDrive size={22} />
</div>
</div>
<div className="glass-card stat-card amber">
<div className="stat-info">
<h3>Trabajos Activos</h3>
<div className="stat-number">{stats?.total_jobs ?? 0}</div>
<div className="stat-sub">
{stats?.active_uploads_count ?? 0} subidas en curso
</div>
</div>
<div className="stat-icon" style={{ color: 'var(--accent-amber)' }}>
<Activity size={22} />
</div>
</div>
</div>
{/* Storage Pool Progress Bar */}
<div className="glass-card" style={{ marginBottom: '28px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Pool de Almacenamiento Proxmox VE (LXC)</h3>
<p style={{ fontSize: '0.8rem', color: 'var(--text-dim)', marginTop: '2px' }}>
Ruta: <code style={{ color: 'var(--accent-cyan)' }}>{stats?.storage.storage_root || '/storage/backups'}</code>
</p>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{ fontSize: '1.2rem', fontWeight: 800, fontFamily: 'var(--font-mono)' }}>
{usagePct}%
</span>
</div>
</div>
<div className="storage-meter-container">
<div className="progress-track">
<div className="progress-fill" style={{ width: `${Math.min(100, usagePct)}%` }} />
</div>
</div>
</div>
{/* Live Transfer Telemetry */}
<LiveTransferMeter uploads={activeUploads} />
{/* Recent Events & Quick Actions Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
<div className="glass-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Clock size={18} color="var(--accent-cyan)" />
Actividad Reciente del Sistema
</h3>
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>Últimos eventos</span>
</div>
<div className="table-container">
<table className="modern-table">
<thead>
<tr>
<th>Hora</th>
<th>Evento</th>
<th>Mensaje</th>
</tr>
</thead>
<tbody>
{events.slice(0, 6).map((evt) => (
<tr key={evt.id}>
<td style={{ fontSize: '0.78rem', color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
{new Date(evt.timestamp).toLocaleTimeString()}
</td>
<td>
<span className={`badge ${
evt.severity === 'ERROR' ? 'badge-error' :
evt.event_type.includes('COMPLETED') ? 'badge-online' : 'badge-syncing'
}`}>
{evt.event_type}
</span>
</td>
<td style={{ fontSize: '0.85rem' }}>{evt.message}</td>
</tr>
))}
{events.length === 0 && (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-dim)', padding: '24px' }}>
No hay eventos registrados recientemente.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="glass-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '12px' }}>OnEver Architecture</h3>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', lineHeight: '1.5' }}>
Plataforma desacoplada en dos capas:
</p>
<ul style={{ fontSize: '0.82rem', color: 'var(--text-dim)', marginTop: '8px', paddingLeft: '18px', lineHeight: '1.6' }}>
<li><strong>Backend API</strong>: FastAPI + PostgreSQL en LXC 1</li>
<li><strong>Storage Node</strong>: Volumen aislado en LXC 2</li>
<li><strong>Motor Chunks</strong>: Subida por bloques de 4MB con reanudación y SHA-256</li>
<li><strong>Agente Windows</strong>: Servicio de fondo con detector de locks SQL</li>
</ul>
</div>
<div style={{ marginTop: '20px' }}>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} onClick={onNavigateToClients}>
<Users size={16} />
Administrar Clientes
</button>
</div>
</div>
</div>
</div>
);
};
+117
View File
@@ -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<EventsViewProps> = ({ events }) => {
const [filterSeverity, setFilterSeverity] = useState<string>('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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Registro de Auditoría & Eventos</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Trazabilidad completa de inicios de sesión, transferencias por bloques, registros y retenciones
</p>
</div>
</div>
<div className="glass-card" style={{ marginBottom: '24px', padding: '16px 20px' }}>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '240px', position: 'relative' }}>
<input
type="text"
className="form-input"
style={{ paddingLeft: '36px' }}
placeholder="Filtrar eventos o palabras clave..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{['ALL', 'INFO', 'WARNING', 'ERROR'].map((sev) => (
<button
key={sev}
className={`btn ${filterSeverity === sev ? 'btn-primary' : 'btn-secondary'}`}
style={{ padding: '8px 14px', fontSize: '0.78rem' }}
onClick={() => setFilterSeverity(sev)}
>
{sev === 'ALL' ? 'Todos' : sev}
</button>
))}
</div>
</div>
</div>
<div className="glass-card">
<div className="table-container">
<table className="modern-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Severidad</th>
<th>Tipo de Evento</th>
<th>Detalle del Mensaje</th>
<th>Cliente ID</th>
<th>IP / Usuario</th>
</tr>
</thead>
<tbody>
{filteredEvents.map((evt) => (
<tr key={evt.id}>
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
{new Date(evt.timestamp).toLocaleString()}
</td>
<td>
<span
className={`badge ${
evt.severity === 'ERROR' ? 'badge-error' :
evt.severity === 'WARNING' ? 'badge-offline' : 'badge-online'
}`}
>
{evt.severity}
</span>
</td>
<td>
<span className="hash-badge" style={{ color: 'var(--accent-cyan)' }}>
{evt.event_type}
</span>
</td>
<td style={{ fontSize: '0.88rem' }}>{evt.message}</td>
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
{evt.client_id ? `#${evt.client_id}` : '—'}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-dim)' }}>
{evt.user_email || evt.ip_address || 'Sistema'}
</td>
</tr>
))}
{filteredEvents.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay registros de eventos que coincidan con los filtros.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
+336
View File
@@ -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<JobsViewProps> = ({ 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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Trabajos de Backup Programados</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Configuración de rutas de origen Windows, filtros de extensión y políticas de retención
</p>
</div>
<button
className="btn btn-primary"
onClick={() => {
if (clients.length === 0) {
alert('Primero debes registrar al menos un cliente Windows.');
return;
}
setFormData((prev) => ({ ...prev, client_id: clients[0].id }));
setShowModal(true);
}}
>
<Plus size={16} />
Crear Trabajo de Backup
</button>
</div>
<div className="glass-card">
<div className="table-container">
<table className="modern-table">
<thead>
<tr>
<th>Job ID</th>
<th>Nombre del Trabajo</th>
<th>Cliente Asignado</th>
<th>Origen en Windows</th>
<th>Filtros</th>
<th>Retención</th>
<th>Horario / Cron</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<span className="hash-badge" style={{ color: 'var(--accent-indigo)' }}>
{job.job_code}
</span>
</td>
<td style={{ fontWeight: 600 }}>{job.name}</td>
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
{getClientName(job.client_id)}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', fontFamily: 'var(--font-mono)' }}>
<Folder size={14} color="var(--accent-cyan)" />
{job.source_path}
</div>
</td>
<td>
<span className="hash-badge">{job.file_patterns}</span>
</td>
<td style={{ fontSize: '0.82rem' }}>
<span style={{ color: '#34d399' }}>{job.keep_daily}d</span> /{' '}
<span style={{ color: '#60a5fa' }}>{job.keep_weekly}w</span> /{' '}
<span style={{ color: '#c084fc' }}>{job.keep_monthly}m</span>
</td>
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-dim)' }}>
{job.schedule_cron}
</td>
<td>
<span
className={`badge ${
job.status === 'RUNNING' ? 'badge-syncing' :
job.status === 'QUEUED' ? 'badge-syncing' : 'badge-online'
}`}
>
{job.status}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--accent-cyan)' }}
onClick={() => handleTrigger(job)}
title="Ejecutar backup ahora"
>
<Play size={13} />
Ejecutar
</button>
<button
className="btn btn-danger"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleDelete(job)}
title="Eliminar trabajo"
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={9} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay trabajos de backup configurados. Haz clic en "Crear Trabajo de Backup".
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Create Job Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-card">
<div className="modal-header">
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Nuevo Trabajo de Backup</h3>
<button
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
onClick={() => setShowModal(false)}
>
<X size={20} />
</button>
</div>
<form onSubmit={handleCreateJob}>
<div className="form-group">
<label>Cliente Windows Destino:</label>
<select
className="form-input"
value={formData.client_id}
onChange={(e) => setFormData({ ...formData, client_id: Number(e.target.value) })}
>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.client_code})
</option>
))}
</select>
</div>
<div className="form-group">
<label>Nombre del Trabajo:</label>
<input
type="text"
required
className="form-input"
placeholder="Ej: SQL Server Producción Diaria"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label>Ruta Origen en Windows:</label>
<input
type="text"
required
className="form-input"
placeholder="C:\SQLBackups"
value={formData.source_path}
onChange={(e) => setFormData({ ...formData, source_path: e.target.value })}
/>
</div>
<div className="form-group">
<label>Filtros de Archivo:</label>
<input
type="text"
required
className="form-input"
placeholder="*.bak,*.mdf"
value={formData.file_patterns}
onChange={(e) => setFormData({ ...formData, file_patterns: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label>Horario (Expresión Cron):</label>
<input
type="text"
required
className="form-input"
placeholder="0 2 * * *"
value={formData.schedule_cron}
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
/>
</div>
<div className="form-group">
<label>Estabilidad Mínima Archivo (segundos):</label>
<input
type="number"
min="10"
required
className="form-input"
value={formData.min_stable_time_seconds}
onChange={(e) => setFormData({ ...formData, min_stable_time_seconds: Number(e.target.value) })}
/>
</div>
</div>
<div className="form-group">
<label>Políticas de Retención (Conservar copias):</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Días (Diario):</span>
<input
type="number"
min="1"
className="form-input"
value={formData.keep_daily}
onChange={(e) => setFormData({ ...formData, keep_daily: Number(e.target.value) })}
/>
</div>
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Semanas (Semanal):</span>
<input
type="number"
min="0"
className="form-input"
value={formData.keep_weekly}
onChange={(e) => setFormData({ ...formData, keep_weekly: Number(e.target.value) })}
/>
</div>
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Meses (Mensual):</span>
<input
type="number"
min="0"
className="form-input"
value={formData.keep_monthly}
onChange={(e) => setFormData({ ...formData, keep_monthly: Number(e.target.value) })}
/>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando...' : 'Crear Trabajo'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+174
View File
@@ -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<LoginViewProps> = ({ onLoginSuccess }) => {
const [email, setEmail] = useState('admin@oneverdrive.local');
const [password, setPassword] = useState('Admin1234!');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
background: 'radial-gradient(ellipse at center, #111827 0%, #0B0F19 100%)',
}}
>
<div className="glass-card" style={{ width: '100%', maxWidth: '440px', padding: '36px 32px' }}>
{/* Brand Header */}
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
<div
style={{
width: '56px',
height: '56px',
margin: '0 auto 16px auto',
background: 'linear-gradient(135deg, #06B6D4, #6366F1)',
borderRadius: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 24px rgba(6, 182, 212, 0.35)',
color: '#FFFFFF',
}}
>
<ShieldCheck size={32} />
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 800, letterSpacing: '-0.02em', color: '#FFFFFF' }}>
OnEver Drive
</h2>
<p style={{ fontSize: '0.84rem', color: '#94A3B8', marginTop: '4px' }}>
Plataforma Centralizada de Backup & Sincronización
</p>
</div>
{/* Error banner */}
{error && (
<div
style={{
backgroundColor: 'rgba(244, 63, 94, 0.15)',
border: '1px solid rgba(244, 63, 94, 0.3)',
borderRadius: '8px',
padding: '12px 14px',
fontSize: '0.84rem',
color: '#FDA4AF',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>{error}</span>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Mail size={14} color="#06B6D4" /> Correo Electrónico:
</label>
<input
type="email"
required
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@oneverdrive.local"
/>
</div>
<div className="form-group" style={{ marginTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Lock size={14} color="#06B6D4" /> Contraseña:
</label>
<input
type="password"
required
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{
width: '100%',
justifyContent: 'center',
padding: '12px',
fontSize: '0.95rem',
marginTop: '24px',
}}
>
<span>{loading ? 'Autenticando...' : 'Iniciar Sesión'}</span>
<ArrowRight size={16} />
</button>
</form>
{/* Quick Demo Credentials */}
<div
style={{
marginTop: '24px',
paddingTop: '20px',
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
textAlign: 'center',
}}
>
<div style={{ fontSize: '0.78rem', color: '#64748B', marginBottom: '8px' }}>
Credenciales de Administrador por Defecto:
</div>
<button
type="button"
onClick={fillDefaultCredentials}
style={{
background: 'rgba(255, 255, 255, 0.04)',
border: '1px dashed rgba(6, 182, 212, 0.4)',
borderRadius: '6px',
color: '#38BDF8',
padding: '6px 12px',
fontSize: '0.76rem',
fontFamily: 'var(--font-mono)',
cursor: 'pointer',
}}
>
admin@oneverdrive.local / Admin1234!
</button>
</div>
</div>
</div>
);
};
+204
View File
@@ -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<RestoreViewProps> = ({ clients }) => {
const [backups, setBackups] = useState<BackupFileItem[]>([]);
const [selectedClientId, setSelectedClientId] = useState<number | undefined>(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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Explorador de Backups & Restauración</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Visualización, verificación de integridad SHA-256 y descarga directa de archivos respaldados
</p>
</div>
</div>
{/* Filter and Search Bar */}
<div className="glass-card" style={{ marginBottom: '24px', padding: '16px 20px' }}>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '240px', position: 'relative' }}>
<input
type="text"
className="form-input"
style={{ paddingLeft: '36px' }}
placeholder="Buscar por nombre de archivo o hash SHA-256..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Filter size={16} color="var(--text-muted)" />
<select
className="form-input"
style={{ width: 'auto' }}
value={selectedClientId || ''}
onChange={(e) => setSelectedClientId(e.target.value ? Number(e.target.value) : undefined)}
>
<option value="">Todos los Clientes</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.client_code})
</option>
))}
</select>
</div>
</div>
</div>
{/* Backups Table */}
<div className="glass-card">
<div className="table-container">
<table className="modern-table">
<thead>
<tr>
<th>Archivo Resguardado</th>
<th>Cliente Origen</th>
<th>Tamaño</th>
<th>Integridad SHA-256</th>
<th>Retención</th>
<th>Fecha de Respaldo</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{filteredBackups.map((backup) => (
<tr key={backup.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<FileCheck size={18} color="var(--accent-emerald)" />
<div>
<div style={{ fontWeight: 600 }}>{backup.filename}</div>
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
{backup.relative_path}
</div>
</div>
</div>
</td>
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
{getClientName(backup.client_id)}
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.86rem' }}>
{formatBytes(backup.file_size)}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<ShieldCheck size={14} color="var(--accent-emerald)" />
<span className="hash-badge" title={backup.sha256}>
{backup.sha256.substring(0, 16)}...
</span>
</div>
</td>
<td>
<span className="badge badge-online">
{backup.retention_tag}
</span>
</td>
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
{new Date(backup.created_at).toLocaleString()}
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-primary"
style={{ padding: '6px 12px', fontSize: '0.78rem' }}
onClick={() => handleDownload(backup)}
title="Descargar archivo íntegro"
>
<Download size={14} />
Descargar
</button>
<button
className="btn btn-danger"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleDelete(backup)}
title="Eliminar de almacenamiento"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{filteredBackups.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
{loading ? 'Cargando copias de seguridad...' : 'No se encontraron archivos de backup.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
+197
View File
@@ -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 <T>(endpoint: string, options: RequestInit = {}): Promise<T> => {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
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<DashboardStats>('/stats'),
// Clients
getClients: () => request<ClientItem[]>('/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<BackupJobItem[]>(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<BackupJobItem>('/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<BackupFileItem[]>(`/backups?${params.toString()}`);
},
deleteBackup: (backupId: number) =>
request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }),
// Events
getEvents: (limit: number = 50) => request<EventLogItem[]>(`/events?limit=${limit}`),
};
+67
View File
@@ -0,0 +1,67 @@
export type WebSocketCallback = (event: { type: string; data: any }) => void;
class WebSocketClient {
private ws: WebSocket | null = null;
private listeners: Set<WebSocketCallback> = 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();
+20
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -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,
}
}
}
});
+10
View File
@@ -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
+147
View File
@@ -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!")
+30
View File
@@ -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!")
+85
View File
@@ -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!")
+75
View File
@@ -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!")
+49
View File
@@ -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
+62
View File
@@ -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)
+82
View File
@@ -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
+193
View File
@@ -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))
+102
View File
@@ -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()
+117
View File
@@ -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
+817
View File
@@ -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()
+190
View File
@@ -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()
+301
View File
@@ -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()
+140
View File
@@ -0,0 +1,140 @@
import os
import sys
import threading
import time
from pathlib import Path
from PIL import Image
import pystray
from pystray import MenuItem as item
# Add agent root to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent.config import load_config, AgentConfig
from agent.service import AgentDaemon
from agent.uploader import ChunkUploader
from create_icons import generate_app_icons
class WindowsTrayAgent:
"""Windows System Tray (Área de Notificaciones) Application for OnEver Drive."""
def __init__(self):
self.config = load_config()
self.daemon = AgentDaemon(self.config)
self.icon = None
self.is_paused = False
self.icon_image = self._load_icon()
def _load_icon(self) -> Image.Image:
assets_dir = Path(__file__).resolve().parent / "assets"
png_path = assets_dir / "icon.png"
if not png_path.exists():
_, png_path = generate_app_icons()
return Image.open(png_path)
def _get_status_text(self) -> str:
if not self.config.client_code:
return "Estado: Sin Registrar"
if self.is_paused:
return "Estado: Pausado"
return f"Estado: Conectado ({self.config.client_code})"
def _toggle_pause(self, icon, item_obj):
self.is_paused = not self.is_paused
if self.is_paused:
self.daemon.stop()
self.notify("Sincronización en pausa", "El servicio de backup ha sido pausado.")
else:
self.daemon.start()
self.notify("Sincronización activa", "El servicio de backup se ha reanudado.")
def _open_gui(self, icon=None, item_obj=None):
def run_gui():
from agent_gui import launch_gui
launch_gui()
threading.Thread(target=run_gui, daemon=True).start()
def _manual_backup(self, icon=None, item_obj=None):
import tkinter as tk
from tkinter import filedialog, messagebox
def run_picker():
root = tk.Tk()
root.withdraw()
filepath = filedialog.askopenfilename(
title="Seleccionar archivo para backup",
filetypes=[("Archivos SQL / Datos", "*.bak;*.mdf;*.*")]
)
if not filepath:
root.destroy()
return
self.notify("Iniciando Backup", f"Preparando transferencia por chunks: {Path(filepath).name}")
def upload_worker():
try:
uploader = ChunkUploader(self.config)
res = uploader.upload_file(Path(filepath))
self.notify("Backup Completado ✓", f"{Path(filepath).name} verificado con éxito en el servidor.")
except Exception as ex:
self.notify("Error en Backup ✗", f"Fallo al subir {Path(filepath).name}: {str(ex)}")
threading.Thread(target=upload_worker, daemon=True).start()
root.destroy()
threading.Thread(target=run_picker, daemon=True).start()
def notify(self, title: str, message: str):
"""Displays a native Windows Notification Balloon."""
if self.icon:
try:
self.icon.notify(message, title)
except Exception:
pass
def _on_exit(self, icon, item_obj):
self.daemon.stop()
icon.stop()
def build_menu(self):
client_title = f"OnEver Drive ({self.config.client_name or 'Agente'})"
return pystray.Menu(
item(client_title, lambda: None, enabled=False),
item(lambda text: self._get_status_text(), lambda: None, enabled=False),
pystray.Menu.SEPARATOR,
item("Abrir Panel de Control...", self._open_gui, default=True),
item("Hacer Backup Manual...", self._manual_backup),
item(lambda text: "Reanudar Servicio" if self.is_paused else "Pausar Servicio", self._toggle_pause),
pystray.Menu.SEPARATOR,
item("Salir", self._on_exit)
)
def run(self):
# Start background daemon worker
if self.config.device_id:
self.daemon.start()
# Create tray icon
self.icon = pystray.Icon(
name="OnEverDrive",
icon=self.icon_image,
title="OnEver Drive — Agente de Backup",
menu=self.build_menu()
)
# Notify on startup
if self.config.client_code:
self.notify("OnEver Drive Activo", f"Agente en ejecución ({self.config.client_code})")
else:
self.notify("OnEver Drive", "Agente iniciado. Requiere registro en el servidor.")
# Run system tray event loop
self.icon.run()
def main():
agent = WindowsTrayAgent()
agent.run()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+93
View File
@@ -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()
+41
View File
@@ -0,0 +1,41 @@
from PIL import Image, ImageDraw
from pathlib import Path
def generate_app_icons():
assets_dir = Path(__file__).resolve().parent / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
size = (256, 256)
img = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw rounded shield / background
# Gradient/Cyan-Indigo background rounded rect
draw.rounded_rectangle([(16, 16), (240, 240)], radius=48, fill="#0F172A", outline="#06B6D4", width=8)
# Draw cloud / shield icon
# Cloud base
draw.ellipse([(60, 110), (140, 170)], fill="#06B6D4")
draw.ellipse([(110, 80), (190, 160)], fill="#38BDF8")
draw.ellipse([(140, 110), (200, 170)], fill="#60A5FA")
draw.rectangle([(100, 130), (170, 170)], fill="#06B6D4")
# Draw upward upload arrow inside cloud
# Arrow head
draw.polygon([(145, 110), (120, 135), (170, 135)], fill="#FFFFFF")
# Arrow body
draw.rectangle([(137, 135), (153, 160)], fill="#FFFFFF")
# Save PNG
png_path = assets_dir / "icon.png"
img.save(png_path, "PNG")
# Save ICO
ico_path = assets_dir / "icon.ico"
img.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
print(f"Icons generated at: {png_path} and {ico_path}")
return ico_path, png_path
if __name__ == "__main__":
generate_app_icons()
@@ -0,0 +1,69 @@
<#
.SYNOPSIS
OnEver Drive — Windows Agent Service Installer
.DESCRIPTION
Installs and configures the OnEver Drive Windows Agent as a background system service.
#>
param (
[string]$ServerUrl = "http://127.0.0.1:8000",
[string]$RegistrationCode = "",
[string]$ClientName = ""
)
Write-Host "==========================================================" -ForegroundColor Cyan
Write-Host " OnEver Drive — Windows Service Setup Script " -ForegroundColor Cyan
Write-Host "==========================================================" -ForegroundColor Cyan
$CurrentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$AgentDir = Split-Path -Parent $CurrentDir
$PythonExe = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
if (-not $PythonExe) {
Write-Error "Python 3.10+ was not found on PATH. Please install Python first."
exit 1
}
Write-Host "[+] Detected Python executable: $PythonExe" -ForegroundColor Green
Write-Host "[+] Agent root directory: $AgentDir" -ForegroundColor Green
# 1. If registration code provided, perform initial registration
if ($RegistrationCode -ne "") {
Write-Host "[*] Registering agent against server: $ServerUrl..." -ForegroundColor Yellow
$RegArgs = @("$AgentDir\agent_cli.py", "register", "--server", $ServerUrl, "--code", $RegistrationCode)
if ($ClientName -ne "") {
$RegArgs += @("--name", $ClientName)
}
& $PythonExe $RegArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Registration failed. Please check registration code and server connectivity."
exit 1
}
}
# 2. Service definition
$ServiceName = "OnEverDriveAgent"
$ServiceDisplayName = "OnEver Drive Backup Agent Service"
$ServiceDescription = "Enterprise chunked backup and sync daemon for OnEver Drive Proxmox platform."
$ServiceBinary = "`"$PythonExe`" `"$AgentDir\agent_cli.py`" daemon"
Write-Host "[*] Registering Windows Service: $ServiceName..." -ForegroundColor Yellow
# Stop and remove existing service if present
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($ExistingService) {
Write-Host "[-] Stopping and removing existing service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
sc.exe delete $ServiceName
Start-Sleep -Seconds 2
}
# Create service using sc.exe
sc.exe create $ServiceName binPath= $ServiceBinary start= auto DisplayName= $ServiceDisplayName
sc.exe description $ServiceName $ServiceDescription
# Configure recovery options (restart service on crash)
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000
Write-Host "[+] Service '$ServiceName' registered successfully!" -ForegroundColor Green
Write-Host "[*] To start the service run: Start-Service $ServiceName" -ForegroundColor Cyan
+5
View File
@@ -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
+6
View File
@@ -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