feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
+45
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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!"}
|
||||||
@@ -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"}
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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 ""
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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 ==="
|
||||||
@@ -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" }`
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
Generated
+1869
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 | {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 | {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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}`),
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://127.0.0.1:8000',
|
||||||
|
ws: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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!")
|
||||||
@@ -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!")
|
||||||
@@ -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!")
|
||||||
@@ -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!")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import math
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
def compute_file_sha256(filepath: Path, buffer_size: int = 1024 * 1024) -> str:
|
||||||
|
"""Calculates full SHA-256 checksum of a file using streaming buffers to avoid memory overhead."""
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(buffer_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hasher.update(chunk)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
class FileChunker:
|
||||||
|
"""Handles splitting large files into discrete chunks and calculating block hashes."""
|
||||||
|
|
||||||
|
def __init__(self, filepath: Path, chunk_size: int = 4 * 1024 * 1024):
|
||||||
|
self.filepath = Path(filepath).resolve()
|
||||||
|
if not self.filepath.exists():
|
||||||
|
raise FileNotFoundError(f"File {filepath} not found")
|
||||||
|
|
||||||
|
self.file_size = self.filepath.stat().st_size
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
self.total_chunks = max(1, math.ceil(self.file_size / self.chunk_size))
|
||||||
|
self._cached_sha256 = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_sha256(self) -> str:
|
||||||
|
if self._cached_sha256 is None:
|
||||||
|
self._cached_sha256 = compute_file_sha256(self.filepath)
|
||||||
|
return self._cached_sha256
|
||||||
|
|
||||||
|
def get_chunk(self, chunk_index: int) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Reads the bytes for chunk `chunk_index` and returns (chunk_data, chunk_sha256).
|
||||||
|
"""
|
||||||
|
if chunk_index < 0 or chunk_index >= self.total_chunks:
|
||||||
|
raise IndexError(f"Chunk index {chunk_index} out of bounds (total: {self.total_chunks})")
|
||||||
|
|
||||||
|
offset = chunk_index * self.chunk_size
|
||||||
|
with open(self.filepath, "rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
data = f.read(self.chunk_size)
|
||||||
|
|
||||||
|
chunk_sha256 = hashlib.sha256(data).hexdigest()
|
||||||
|
return data, chunk_sha256
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
AGENT_HOME = Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / "OnEverDrive"
|
||||||
|
if not AGENT_HOME.exists():
|
||||||
|
try:
|
||||||
|
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
AGENT_HOME = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
AGENT_HOME.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
CONFIG_FILE = AGENT_HOME / "config.json"
|
||||||
|
|
||||||
|
class LocalFolderJob(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||||
|
name: str
|
||||||
|
source_path: str
|
||||||
|
file_patterns: str = "*.bak,*.mdf"
|
||||||
|
schedule_interval_minutes: int = 60
|
||||||
|
min_stable_seconds: int = 60
|
||||||
|
is_active: bool = True
|
||||||
|
last_backup_at: Optional[str] = None
|
||||||
|
last_status: Optional[str] = "En espera"
|
||||||
|
|
||||||
|
class AgentConfig(BaseModel):
|
||||||
|
server_url: str = "http://127.0.0.1:8000"
|
||||||
|
client_code: Optional[str] = None
|
||||||
|
device_id: Optional[str] = None
|
||||||
|
device_token: Optional[str] = None
|
||||||
|
client_name: Optional[str] = None
|
||||||
|
chunk_size: int = 4 * 1024 * 1024 # 4 MB
|
||||||
|
heartbeat_interval_seconds: int = 30
|
||||||
|
min_stable_time_seconds: int = 60
|
||||||
|
log_level: str = "INFO"
|
||||||
|
local_folders: List[LocalFolderJob] = []
|
||||||
|
|
||||||
|
# Notification preferences (configurable from GUI and Tray)
|
||||||
|
enable_notifications: bool = True
|
||||||
|
notify_on_start: bool = True
|
||||||
|
notify_on_complete: bool = True
|
||||||
|
notify_on_error: bool = True
|
||||||
|
|
||||||
|
def load_config() -> AgentConfig:
|
||||||
|
"""Loads agent configuration from disk or returns default configuration."""
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return AgentConfig(**data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return AgentConfig()
|
||||||
|
|
||||||
|
def save_config(config: AgentConfig) -> None:
|
||||||
|
"""Persists agent configuration to disk."""
|
||||||
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config.model_dump(), f, indent=2)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
def is_file_locked(filepath: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if a file is locked exclusively by another process (e.g. SQL Server writing .bak).
|
||||||
|
Attempts to open the file with read-shared permissions.
|
||||||
|
"""
|
||||||
|
if not filepath.exists() or not filepath.is_file():
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# On Windows, try opening in append/read mode to detect exclusive write lock
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
return False
|
||||||
|
except (PermissionError, IOError, OSError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval_seconds: float = 0.5) -> bool:
|
||||||
|
"""
|
||||||
|
Ensures that a file is not actively growing or being modified.
|
||||||
|
Verifies that modification timestamp and size are stable.
|
||||||
|
"""
|
||||||
|
if is_file_locked(filepath):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat_initial = filepath.stat()
|
||||||
|
initial_size = stat_initial.st_size
|
||||||
|
initial_mtime = stat_initial.st_mtime
|
||||||
|
|
||||||
|
# Check if the file was modified very recently compared to current time
|
||||||
|
current_time = time.time()
|
||||||
|
if (current_time - initial_mtime) < min_stable_seconds:
|
||||||
|
# File was modified less than min_stable_seconds ago; perform sample check
|
||||||
|
time.sleep(sample_interval_seconds)
|
||||||
|
stat_second = filepath.stat()
|
||||||
|
if stat_second.st_size != initial_size or stat_second.st_mtime != initial_mtime:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
class DirectoryScanner:
|
||||||
|
"""Scans Windows source paths for files matching specific backup patterns."""
|
||||||
|
|
||||||
|
def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60):
|
||||||
|
self.source_path = Path(source_path).resolve()
|
||||||
|
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
||||||
|
self.min_stable_seconds = min_stable_seconds
|
||||||
|
|
||||||
|
def scan(self) -> List[Path]:
|
||||||
|
"""Returns list of all matching files that are stable and ready for backup."""
|
||||||
|
if not self.source_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
matched_files: List[Path] = []
|
||||||
|
|
||||||
|
if self.source_path.is_file():
|
||||||
|
if self._matches_patterns(self.source_path.name):
|
||||||
|
matched_files.append(self.source_path)
|
||||||
|
return matched_files
|
||||||
|
|
||||||
|
for root, _, files in os.walk(self.source_path):
|
||||||
|
for file in files:
|
||||||
|
if self._matches_patterns(file):
|
||||||
|
full_path = Path(root) / file
|
||||||
|
matched_files.append(full_path)
|
||||||
|
|
||||||
|
return matched_files
|
||||||
|
|
||||||
|
def _matches_patterns(self, filename: str) -> bool:
|
||||||
|
if not self.patterns or "*" in self.patterns:
|
||||||
|
return True
|
||||||
|
for pattern in self.patterns:
|
||||||
|
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import time
|
||||||
|
import socket
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable, Dict, Any, List
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from agent.config import AgentConfig, load_config, save_config, LocalFolderJob
|
||||||
|
from agent.scanner import DirectoryScanner, is_file_stable
|
||||||
|
from agent.chunker import compute_file_sha256
|
||||||
|
from agent.uploader import ChunkUploader
|
||||||
|
from agent.state_db import state_db
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("OnEverAgent")
|
||||||
|
|
||||||
|
class AgentDaemon:
|
||||||
|
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Optional[AgentConfig] = None,
|
||||||
|
on_started: Optional[Callable[[str, int], None]] = None,
|
||||||
|
on_progress: Optional[Callable[[str, int, int, float], None]] = None,
|
||||||
|
on_completed: Optional[Callable[[str, str, int], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str, str], None]] = None,
|
||||||
|
on_status: Optional[Callable[[str, str], None]] = None
|
||||||
|
):
|
||||||
|
self.config = config or load_config()
|
||||||
|
self.running = False
|
||||||
|
self.uploader = ChunkUploader(self.config)
|
||||||
|
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||||
|
self._worker_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# Event callbacks
|
||||||
|
self.on_started = on_started
|
||||||
|
self.on_progress = on_progress
|
||||||
|
self.on_completed = on_completed
|
||||||
|
self.on_error = on_error
|
||||||
|
self.on_status = on_status
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.config = load_config()
|
||||||
|
if not self.config.device_id or not self.config.device_token:
|
||||||
|
logger.warning("Agent is not registered yet. Waiting for registration.")
|
||||||
|
if self.on_status:
|
||||||
|
self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})")
|
||||||
|
logger.info(f"Target Server: {self.config.server_url}")
|
||||||
|
|
||||||
|
if self.on_status:
|
||||||
|
self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})")
|
||||||
|
|
||||||
|
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
||||||
|
self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True)
|
||||||
|
|
||||||
|
self._heartbeat_thread.start()
|
||||||
|
self._worker_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
logger.info("Stopping agent daemon...")
|
||||||
|
self.running = False
|
||||||
|
if self.on_status:
|
||||||
|
self.on_status("PAUSED", "Servicio en pausa.")
|
||||||
|
|
||||||
|
def _get_headers(self):
|
||||||
|
return {
|
||||||
|
"X-Device-Id": self.config.device_id,
|
||||||
|
"X-Device-Token": self.config.device_token
|
||||||
|
}
|
||||||
|
|
||||||
|
def _heartbeat_loop(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
||||||
|
resp = client.get("/api/jobs/agent/assigned")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
logger.debug("Heartbeat acknowledged by server.")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(f"Heartbeat failed: {str(ex)}")
|
||||||
|
|
||||||
|
time.sleep(self.config.heartbeat_interval_seconds)
|
||||||
|
|
||||||
|
def _backup_worker_loop(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
self._run_backup_cycle()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error during backup cycle: {str(ex)}")
|
||||||
|
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
|
def _run_backup_cycle(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.uploader.config = self.config
|
||||||
|
|
||||||
|
# 1. Fetch server-assigned jobs
|
||||||
|
server_jobs = []
|
||||||
|
try:
|
||||||
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
|
||||||
|
resp = client.get("/api/jobs/agent/assigned")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
server_jobs = resp.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Combine server jobs + user local folders
|
||||||
|
all_jobs = []
|
||||||
|
for sj in server_jobs:
|
||||||
|
all_jobs.append({
|
||||||
|
"job_id": sj.get("id"),
|
||||||
|
"name": sj.get("name"),
|
||||||
|
"source_path": sj.get("source_path"),
|
||||||
|
"file_patterns": sj.get("file_patterns", "*.*"),
|
||||||
|
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
for lj in self.config.local_folders:
|
||||||
|
if lj.is_active:
|
||||||
|
all_jobs.append({
|
||||||
|
"job_id": None,
|
||||||
|
"local_job_id": lj.id,
|
||||||
|
"name": lj.name,
|
||||||
|
"source_path": lj.source_path,
|
||||||
|
"file_patterns": lj.file_patterns,
|
||||||
|
"min_stable_seconds": lj.min_stable_seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Process jobs
|
||||||
|
for job in all_jobs:
|
||||||
|
source_path = job["source_path"]
|
||||||
|
file_patterns = job["file_patterns"]
|
||||||
|
min_stable = job["min_stable_seconds"]
|
||||||
|
job_name = job["name"]
|
||||||
|
|
||||||
|
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
||||||
|
files = scanner.scan()
|
||||||
|
|
||||||
|
for filepath in files:
|
||||||
|
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||||
|
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_sha = compute_file_sha256(filepath)
|
||||||
|
if state_db.is_file_already_backed_up(str(filepath), current_sha):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_size_bytes = filepath.stat().st_size
|
||||||
|
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
# Single notification when the process starts
|
||||||
|
if self.on_started:
|
||||||
|
self.on_started(filepath.name, file_size_bytes)
|
||||||
|
|
||||||
|
def on_chunk_progress(done, total, pct):
|
||||||
|
if self.on_progress:
|
||||||
|
self.on_progress(filepath.name, done, total, pct)
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = self.uploader.upload_file(
|
||||||
|
filepath,
|
||||||
|
job_id=job.get("job_id"),
|
||||||
|
progress_callback=on_chunk_progress
|
||||||
|
)
|
||||||
|
logger.info(f"Successfully backed up {filepath.name}!")
|
||||||
|
|
||||||
|
# Update local folder last backup status
|
||||||
|
if "local_job_id" in job:
|
||||||
|
for folder in self.config.local_folders:
|
||||||
|
if folder.id == job["local_job_id"]:
|
||||||
|
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
folder.last_status = "Backup Exitoso"
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
|
# Single notification when the process finishes
|
||||||
|
if self.on_completed:
|
||||||
|
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
||||||
|
if self.on_error:
|
||||||
|
self.on_error(filepath.name, str(ex))
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from agent.config import AGENT_HOME
|
||||||
|
|
||||||
|
STATE_DB_PATH = AGENT_HOME / "agent_state.db"
|
||||||
|
|
||||||
|
class StateDatabase:
|
||||||
|
"""Local SQLite database for agent offline resiliency, session resume tracking and file caching."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path = STATE_DB_PATH):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(str(self.db_path))
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
with self._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS upload_sessions (
|
||||||
|
filepath TEXT PRIMARY KEY,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
session_code TEXT NOT NULL,
|
||||||
|
total_chunks INTEGER NOT NULL,
|
||||||
|
chunk_size INTEGER NOT NULL,
|
||||||
|
completed_chunks INTEGER DEFAULT 0,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_updated TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS completed_files (
|
||||||
|
filepath TEXT PRIMARY KEY,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
job_id INTEGER,
|
||||||
|
last_backup_time TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def save_session(
|
||||||
|
self,
|
||||||
|
filepath: str,
|
||||||
|
sha256: str,
|
||||||
|
session_code: str,
|
||||||
|
total_chunks: int,
|
||||||
|
chunk_size: int,
|
||||||
|
status: str = "UPLOADING"
|
||||||
|
):
|
||||||
|
with self._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO upload_sessions
|
||||||
|
(filepath, sha256, session_code, total_chunks, chunk_size, status, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (filepath, sha256, session_code, total_chunks, chunk_size, status, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_session(self, filepath: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT filepath, sha256, session_code, total_chunks, chunk_size, completed_chunks, status FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
"filepath": row[0],
|
||||||
|
"sha256": row[1],
|
||||||
|
"session_code": row[2],
|
||||||
|
"total_chunks": row[3],
|
||||||
|
"chunk_size": row[4],
|
||||||
|
"completed_chunks": row[5],
|
||||||
|
"status": row[6]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_session_completed(self, filepath: str, sha256: str, file_size: int, job_id: Optional[int] = None):
|
||||||
|
with self._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cursor.execute("DELETE FROM upload_sessions WHERE filepath = ?", (filepath,))
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO completed_files (filepath, sha256, file_size, job_id, last_backup_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (filepath, sha256, file_size, job_id, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def is_file_already_backed_up(self, filepath: str, current_sha256: str) -> bool:
|
||||||
|
with self._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT sha256 FROM completed_files WHERE filepath = ?", (filepath,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0] == current_sha256:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
state_db = StateDatabase()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable, Dict, Any, List
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from agent.config import AgentConfig, load_config
|
||||||
|
from agent.chunker import FileChunker
|
||||||
|
from agent.state_db import state_db
|
||||||
|
|
||||||
|
class ChunkUploader:
|
||||||
|
"""HTTP/HTTPS Chunk transfer client with automatic resumption and retry backoff."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[AgentConfig] = None):
|
||||||
|
self.config = config or load_config()
|
||||||
|
|
||||||
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
|
if not self.config.device_id or not self.config.device_token:
|
||||||
|
raise ValueError("Agent is not registered. Run 'agent_cli.py register' first.")
|
||||||
|
return {
|
||||||
|
"X-Device-Id": self.config.device_id,
|
||||||
|
"X-Device-Token": self.config.device_token
|
||||||
|
}
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
self,
|
||||||
|
filepath: Path,
|
||||||
|
job_id: Optional[int] = None,
|
||||||
|
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
||||||
|
max_retries: int = 5
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Uploads a file chunk by chunk. If interrupted, querying the server will return
|
||||||
|
already received chunks, allowing immediate resumption without re-uploading completed parts.
|
||||||
|
"""
|
||||||
|
filepath = Path(filepath).resolve()
|
||||||
|
chunker = FileChunker(filepath=filepath, chunk_size=self.config.chunk_size)
|
||||||
|
full_sha256 = chunker.full_sha256
|
||||||
|
filename = filepath.name
|
||||||
|
|
||||||
|
headers = self._get_headers()
|
||||||
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
|
||||||
|
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
|
||||||
|
# 1. Initialize or resume upload session
|
||||||
|
init_payload = {
|
||||||
|
"filename": filename,
|
||||||
|
"file_size": chunker.file_size,
|
||||||
|
"sha256": full_sha256,
|
||||||
|
"chunk_size": chunker.chunk_size,
|
||||||
|
"job_id": job_id
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.post("/api/upload/session", json=init_payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
session_data = resp.json()
|
||||||
|
|
||||||
|
session_code = session_data["session_code"]
|
||||||
|
total_chunks = session_data["total_chunks"]
|
||||||
|
received_chunks = set(session_data.get("received_chunks", []))
|
||||||
|
|
||||||
|
state_db.save_session(
|
||||||
|
filepath=str(filepath),
|
||||||
|
sha256=full_sha256,
|
||||||
|
session_code=session_code,
|
||||||
|
total_chunks=total_chunks,
|
||||||
|
chunk_size=chunker.chunk_size,
|
||||||
|
status="UPLOADING"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Upload missing chunks
|
||||||
|
missing_chunks = [i for i in range(total_chunks) if i not in received_chunks]
|
||||||
|
|
||||||
|
for idx in missing_chunks:
|
||||||
|
chunk_bytes, chunk_hash = chunker.get_chunk(idx)
|
||||||
|
|
||||||
|
# Retry loop with exponential backoff for network resilience
|
||||||
|
success = False
|
||||||
|
attempt = 0
|
||||||
|
while not success and attempt < max_retries:
|
||||||
|
try:
|
||||||
|
chunk_headers = {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"X-Chunk-Index": str(idx),
|
||||||
|
"X-Chunk-SHA256": chunk_hash
|
||||||
|
}
|
||||||
|
c_resp = client.post(
|
||||||
|
f"/api/upload/{session_code}/chunk?chunk_index={idx}&chunk_sha256={chunk_hash}",
|
||||||
|
content=chunk_bytes,
|
||||||
|
headers=chunk_headers
|
||||||
|
)
|
||||||
|
c_resp.raise_for_status()
|
||||||
|
success = True
|
||||||
|
except Exception as ex:
|
||||||
|
attempt += 1
|
||||||
|
if attempt >= max_retries:
|
||||||
|
raise ConnectionError(f"Failed to upload chunk {idx} after {max_retries} attempts: {str(ex)}")
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
received_chunks.add(idx)
|
||||||
|
if progress_callback:
|
||||||
|
pct = round((len(received_chunks) / total_chunks) * 100, 2)
|
||||||
|
progress_callback(len(received_chunks), total_chunks, pct)
|
||||||
|
|
||||||
|
# 3. Complete and verify full file assembly on server
|
||||||
|
complete_resp = client.post(f"/api/upload/{session_code}/complete")
|
||||||
|
complete_resp.raise_for_status()
|
||||||
|
result = complete_resp.json()
|
||||||
|
|
||||||
|
# 4. Update local state database
|
||||||
|
state_db.mark_session_completed(
|
||||||
|
filepath=str(filepath),
|
||||||
|
sha256=full_sha256,
|
||||||
|
file_size=chunker.file_size,
|
||||||
|
job_id=job_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
||||||
|
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QTabWidget, QLabel, QPushButton, QLineEdit, QTableWidget,
|
||||||
|
QTableWidgetItem, QHeaderView, QProgressBar, QFileDialog,
|
||||||
|
QMessageBox, QSystemTrayIcon, QMenu, QDialog, QFormLayout,
|
||||||
|
QComboBox, QSpinBox, QFrame, QCheckBox
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add agent root to sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from agent.config import load_config, save_config, AgentConfig, LocalFolderJob
|
||||||
|
from agent.service import AgentDaemon
|
||||||
|
from agent.uploader import ChunkUploader
|
||||||
|
from agent.chunker import compute_file_sha256
|
||||||
|
from agent.state_db import state_db
|
||||||
|
from create_icons import generate_app_icons
|
||||||
|
|
||||||
|
# --- Modern Dark QSS Stylesheet ---
|
||||||
|
DARK_QSS = """
|
||||||
|
QMainWindow, QWidget {
|
||||||
|
background-color: #0B0F19;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background-color: #111827;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab {
|
||||||
|
background: #1E293B;
|
||||||
|
color: #94A3B8;
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background: #06B6D4;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:hover:!selected {
|
||||||
|
background: #334155;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFrame.card {
|
||||||
|
background-color: #1E293B;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit, QComboBox, QSpinBox {
|
||||||
|
background-color: #0B0F19;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit:focus, QComboBox:focus, QSpinBox:focus {
|
||||||
|
border: 1px solid #06B6D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox {
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 13px;
|
||||||
|
spacing: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox::indicator {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
background-color: #0B0F19;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox::indicator:checked {
|
||||||
|
background-color: #06B6D4;
|
||||||
|
border-color: #06B6D4;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.primary {
|
||||||
|
background-color: #06B6D4;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.primary:hover {
|
||||||
|
background-color: #0891B2;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.success {
|
||||||
|
background-color: #10B981;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.success:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.danger {
|
||||||
|
background-color: rgba(244, 63, 94, 0.2);
|
||||||
|
color: #FDA4AF;
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton.danger:hover {
|
||||||
|
background-color: rgba(244, 63, 94, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar {
|
||||||
|
background-color: #1E293B;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: bold;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06B6D4, stop:1 #6366F1);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget {
|
||||||
|
background-color: #0B0F19;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
gridline-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 8px;
|
||||||
|
color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget::item:selected {
|
||||||
|
background-color: rgba(6, 182, 212, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #1E293B;
|
||||||
|
color: #94A3B8;
|
||||||
|
padding: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu {
|
||||||
|
background-color: #1E293B;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #06B6D4;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class WorkerSignals(QThread):
|
||||||
|
started_signal = pyqtSignal(str, int)
|
||||||
|
progress_signal = pyqtSignal(str, int, int, float)
|
||||||
|
completed_signal = pyqtSignal(str, str, int)
|
||||||
|
error_signal = pyqtSignal(str, str)
|
||||||
|
status_signal = pyqtSignal(str, str)
|
||||||
|
|
||||||
|
class AddFolderDialog(QDialog):
|
||||||
|
"""Dialog for selecting and configuring a Windows backup folder."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
|
||||||
|
self.resize(500, 320)
|
||||||
|
self.setStyleSheet(DARK_QSS)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
form.setSpacing(12)
|
||||||
|
|
||||||
|
self.txt_name = QLineEdit()
|
||||||
|
self.txt_name.setPlaceholderText("Ej: Base de Datos SQL Producción")
|
||||||
|
form.addRow("Nombre descriptivo:", self.txt_name)
|
||||||
|
|
||||||
|
path_layout = QHBoxLayout()
|
||||||
|
self.txt_path = QLineEdit()
|
||||||
|
self.txt_path.setPlaceholderText("C:\\SQLBackups")
|
||||||
|
btn_browse = QPushButton("Explorar...")
|
||||||
|
btn_browse.clicked.connect(self._browse_folder)
|
||||||
|
path_layout.addWidget(self.txt_path)
|
||||||
|
path_layout.addWidget(btn_browse)
|
||||||
|
form.addRow("Ruta en Windows:", path_layout)
|
||||||
|
|
||||||
|
self.txt_patterns = QLineEdit()
|
||||||
|
self.txt_patterns.setText("*.bak,*.mdf")
|
||||||
|
form.addRow("Filtros de archivo:", self.txt_patterns)
|
||||||
|
|
||||||
|
self.spin_interval = QSpinBox()
|
||||||
|
self.spin_interval.setRange(5, 1440)
|
||||||
|
self.spin_interval.setValue(60)
|
||||||
|
self.spin_interval.setSuffix(" min")
|
||||||
|
form.addRow("Frecuencia de sondeo:", self.spin_interval)
|
||||||
|
|
||||||
|
self.spin_stable = QSpinBox()
|
||||||
|
self.spin_stable.setRange(10, 600)
|
||||||
|
self.spin_stable.setValue(60)
|
||||||
|
self.spin_stable.setSuffix(" seg")
|
||||||
|
form.addRow("Estabilidad de archivo (Locks):", self.spin_stable)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
btn_layout.addStretch()
|
||||||
|
btn_cancel = QPushButton("Cancelar")
|
||||||
|
btn_cancel.clicked.connect(self.reject)
|
||||||
|
btn_save = QPushButton("Guardar Carpeta")
|
||||||
|
btn_save.setProperty("class", "primary")
|
||||||
|
btn_save.clicked.connect(self._validate_and_accept)
|
||||||
|
|
||||||
|
btn_layout.addWidget(btn_cancel)
|
||||||
|
btn_layout.addWidget(btn_save)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
def _browse_folder(self):
|
||||||
|
folder = QFileDialog.getExistingDirectory(self, "Seleccionar carpeta para backup")
|
||||||
|
if folder:
|
||||||
|
self.txt_path.setText(folder)
|
||||||
|
if not self.txt_name.text():
|
||||||
|
self.txt_name.setText(Path(folder).name)
|
||||||
|
|
||||||
|
def _validate_and_accept(self):
|
||||||
|
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
|
||||||
|
QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")
|
||||||
|
return
|
||||||
|
if not self.txt_name.text():
|
||||||
|
self.txt_name.setText(Path(self.txt_path.text()).name)
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_data(self) -> LocalFolderJob:
|
||||||
|
return LocalFolderJob(
|
||||||
|
name=self.txt_name.text().strip(),
|
||||||
|
source_path=self.txt_path.text().strip(),
|
||||||
|
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
||||||
|
schedule_interval_minutes=self.spin_interval.value(),
|
||||||
|
min_stable_seconds=self.spin_stable.value()
|
||||||
|
)
|
||||||
|
|
||||||
|
class OnEverDriveMainWindow(QMainWindow):
|
||||||
|
"""Main modern desktop GUI and tray controller for OnEver Drive Windows Agent."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("OnEver Drive — Agente de Backup Windows")
|
||||||
|
self.resize(780, 580)
|
||||||
|
self.setStyleSheet(DARK_QSS)
|
||||||
|
|
||||||
|
self.config = load_config()
|
||||||
|
self.app_icon = self._load_app_icon()
|
||||||
|
self.setWindowIcon(self.app_icon)
|
||||||
|
|
||||||
|
# Background Worker Daemon
|
||||||
|
self.signals = WorkerSignals()
|
||||||
|
self.daemon = AgentDaemon(
|
||||||
|
config=self.config,
|
||||||
|
on_started=lambda f, b: self.signals.started_signal.emit(f, b),
|
||||||
|
on_progress=lambda f, d, t, p: self.signals.progress_signal.emit(f, d, t, p),
|
||||||
|
on_completed=lambda f, s, b: self.signals.completed_signal.emit(f, s, b),
|
||||||
|
on_error=lambda f, e: self.signals.error_signal.emit(f, e),
|
||||||
|
on_status=lambda s, m: self.signals.status_signal.emit(s, m)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connect_signals()
|
||||||
|
self._init_ui()
|
||||||
|
self._init_tray()
|
||||||
|
|
||||||
|
if self.config.device_id:
|
||||||
|
self.daemon.start()
|
||||||
|
|
||||||
|
def _load_app_icon(self) -> QIcon:
|
||||||
|
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||||
|
png_path = assets_dir / "icon.png"
|
||||||
|
if not png_path.exists():
|
||||||
|
_, png_path = generate_app_icons()
|
||||||
|
return QIcon(str(png_path))
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.signals.started_signal.connect(self._on_backup_started)
|
||||||
|
self.signals.progress_signal.connect(self._on_live_progress)
|
||||||
|
self.signals.completed_signal.connect(self._on_backup_completed)
|
||||||
|
self.signals.error_signal.connect(self._on_backup_error)
|
||||||
|
self.signals.status_signal.connect(self._on_daemon_status)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
main_layout = QVBoxLayout(central)
|
||||||
|
main_layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
main_layout.setSpacing(16)
|
||||||
|
|
||||||
|
# Header Bar
|
||||||
|
header = QHBoxLayout()
|
||||||
|
title_box = QVBoxLayout()
|
||||||
|
lbl_title = QLabel("OnEver Drive")
|
||||||
|
lbl_title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold))
|
||||||
|
lbl_sub = QLabel("Agente Empresarial de Sincronización y Backup para Windows")
|
||||||
|
lbl_sub.setStyleSheet("color: #94A3B8; font-size: 11px;")
|
||||||
|
title_box.addWidget(lbl_title)
|
||||||
|
title_box.addWidget(lbl_sub)
|
||||||
|
header.addLayout(title_box)
|
||||||
|
|
||||||
|
header.addStretch()
|
||||||
|
|
||||||
|
self.lbl_status_badge = QLabel("● Desconectado")
|
||||||
|
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||||||
|
header.addWidget(self.lbl_status_badge)
|
||||||
|
|
||||||
|
main_layout.addLayout(header)
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.tab_dashboard = QWidget()
|
||||||
|
self.tab_folders = QWidget()
|
||||||
|
self.tab_config = QWidget()
|
||||||
|
self.tab_history = QWidget()
|
||||||
|
|
||||||
|
self.tabs.addTab(self.tab_dashboard, "Dashboard")
|
||||||
|
self.tabs.addTab(self.tab_folders, "Carpetas de Backup")
|
||||||
|
self.tabs.addTab(self.tab_config, "Servidor & Config")
|
||||||
|
self.tabs.addTab(self.tab_history, "Historial de Archivos")
|
||||||
|
|
||||||
|
main_layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
self._build_tab_dashboard()
|
||||||
|
self._build_tab_folders()
|
||||||
|
self._build_tab_config()
|
||||||
|
self._build_tab_history()
|
||||||
|
|
||||||
|
self._update_header_status()
|
||||||
|
|
||||||
|
# --- TAB 1: DASHBOARD ---
|
||||||
|
def _build_tab_dashboard(self):
|
||||||
|
layout = QVBoxLayout(self.tab_dashboard)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(16)
|
||||||
|
|
||||||
|
card_tel = QFrame()
|
||||||
|
card_tel.setProperty("class", "card")
|
||||||
|
tel_layout = QVBoxLayout(card_tel)
|
||||||
|
|
||||||
|
lbl_sec = QLabel("Transferencia en Vivo (Motor de Chunks)")
|
||||||
|
lbl_sec.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||||||
|
tel_layout.addWidget(lbl_sec)
|
||||||
|
|
||||||
|
self.lbl_transfer_info = QLabel("Estado: En espera de cambios en carpetas monitoreadas...")
|
||||||
|
self.lbl_transfer_info.setStyleSheet("color: #94A3B8;")
|
||||||
|
tel_layout.addWidget(self.lbl_transfer_info)
|
||||||
|
|
||||||
|
self.pbar_transfer = QProgressBar()
|
||||||
|
self.pbar_transfer.setValue(0)
|
||||||
|
tel_layout.addWidget(self.pbar_transfer)
|
||||||
|
|
||||||
|
self.lbl_chunk_details = QLabel("Chunks: 0 / 0 | Motor por Bloques de 4MB | SHA-256: —")
|
||||||
|
self.lbl_chunk_details.setStyleSheet("color: #64748B; font-family: 'Consolas', monospace; font-size: 11px;")
|
||||||
|
tel_layout.addWidget(self.lbl_chunk_details)
|
||||||
|
|
||||||
|
layout.addWidget(card_tel)
|
||||||
|
|
||||||
|
card_info = QFrame()
|
||||||
|
card_info.setProperty("class", "card")
|
||||||
|
info_layout = QVBoxLayout(card_info)
|
||||||
|
|
||||||
|
lbl_info_title = QLabel("Información del Dispositivo")
|
||||||
|
lbl_info_title.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
|
||||||
|
info_layout.addWidget(lbl_info_title)
|
||||||
|
|
||||||
|
self.lbl_dash_client = QLabel("Cliente ID: —")
|
||||||
|
self.lbl_dash_server = QLabel("Servidor Proxmox: —")
|
||||||
|
self.lbl_dash_folders = QLabel("Carpetas en Monitoreo: 0")
|
||||||
|
|
||||||
|
info_layout.addWidget(self.lbl_dash_client)
|
||||||
|
info_layout.addWidget(self.lbl_dash_server)
|
||||||
|
info_layout.addWidget(self.lbl_dash_folders)
|
||||||
|
|
||||||
|
layout.addWidget(card_info)
|
||||||
|
|
||||||
|
btn_box = QHBoxLayout()
|
||||||
|
btn_backup_all = QPushButton("▶ Iniciar Sincronización Manual Ahora")
|
||||||
|
btn_backup_all.setProperty("class", "primary")
|
||||||
|
btn_backup_all.clicked.connect(self._trigger_all_backups)
|
||||||
|
|
||||||
|
btn_box.addWidget(btn_backup_all)
|
||||||
|
layout.addLayout(btn_box)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# --- TAB 2: CARPETAS DE BACKUP ---
|
||||||
|
def _build_tab_folders(self):
|
||||||
|
layout = QVBoxLayout(self.tab_folders)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
top_bar = QHBoxLayout()
|
||||||
|
lbl = QLabel("Carpetas de Windows Monitoreadas")
|
||||||
|
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||||||
|
top_bar.addWidget(lbl)
|
||||||
|
top_bar.addStretch()
|
||||||
|
|
||||||
|
btn_add = QPushButton("➕ Añadir Carpeta...")
|
||||||
|
btn_add.setProperty("class", "primary")
|
||||||
|
btn_add.clicked.connect(self._show_add_folder_dialog)
|
||||||
|
top_bar.addWidget(btn_add)
|
||||||
|
layout.addLayout(top_bar)
|
||||||
|
|
||||||
|
self.tbl_folders = QTableWidget(0, 5)
|
||||||
|
self.tbl_folders.setHorizontalHeaderLabels(["Nombre", "Ruta en Windows", "Filtros", "Intervalo", "Último Estado"])
|
||||||
|
self.tbl_folders.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.tbl_folders.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
layout.addWidget(self.tbl_folders)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_delete = QPushButton("🗑️ Eliminar Carpeta")
|
||||||
|
btn_delete.setProperty("class", "danger")
|
||||||
|
btn_delete.clicked.connect(self._delete_selected_folder)
|
||||||
|
btn_row.addWidget(btn_delete)
|
||||||
|
btn_row.addStretch()
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
self._refresh_folders_table()
|
||||||
|
|
||||||
|
# --- TAB 3: CONFIGURACIÓN & NOTIFICACIONES ---
|
||||||
|
def _build_tab_config(self):
|
||||||
|
layout = QVBoxLayout(self.tab_config)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(16)
|
||||||
|
|
||||||
|
# Server Card
|
||||||
|
card_srv = QFrame()
|
||||||
|
card_srv.setProperty("class", "card")
|
||||||
|
form = QFormLayout(card_srv)
|
||||||
|
form.setSpacing(12)
|
||||||
|
|
||||||
|
lbl = QLabel("Conexión con el Servidor Central Proxmox VE")
|
||||||
|
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||||||
|
form.addRow(lbl)
|
||||||
|
|
||||||
|
self.txt_server_url = QLineEdit()
|
||||||
|
self.txt_server_url.setText(self.config.server_url)
|
||||||
|
|
||||||
|
btn_ping = QPushButton("🔍 Probar Conexión")
|
||||||
|
btn_ping.clicked.connect(self._test_server_connection)
|
||||||
|
|
||||||
|
srv_box = QHBoxLayout()
|
||||||
|
srv_box.addWidget(self.txt_server_url)
|
||||||
|
srv_box.addWidget(btn_ping)
|
||||||
|
form.addRow("URL Servidor:", srv_box)
|
||||||
|
|
||||||
|
self.txt_reg_code = QLineEdit()
|
||||||
|
self.txt_reg_code.setPlaceholderText("Código generado en la Web (ej: OED-XXXX-XXXX)")
|
||||||
|
form.addRow("Código de Registro:", self.txt_reg_code)
|
||||||
|
|
||||||
|
btn_register = QPushButton("🚀 Registrar / Re-vincular Dispositivo")
|
||||||
|
btn_register.setProperty("class", "primary")
|
||||||
|
btn_register.clicked.connect(self._register_device_api)
|
||||||
|
form.addRow("", btn_register)
|
||||||
|
|
||||||
|
layout.addWidget(card_srv)
|
||||||
|
|
||||||
|
# Notifications Preferences Card (Clean & Non-invasive)
|
||||||
|
card_notif = QFrame()
|
||||||
|
card_notif.setProperty("class", "card")
|
||||||
|
notif_layout = QVBoxLayout(card_notif)
|
||||||
|
notif_layout.setSpacing(10)
|
||||||
|
|
||||||
|
lbl_notif = QLabel("Preferencias de Notificaciones en Windows")
|
||||||
|
lbl_notif.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||||||
|
notif_layout.addWidget(lbl_notif)
|
||||||
|
|
||||||
|
self.chk_notif_main = QCheckBox("Habilitar notificaciones en el Área de Notificaciones (System Tray)")
|
||||||
|
self.chk_notif_main.setChecked(self.config.enable_notifications)
|
||||||
|
self.chk_notif_main.stateChanged.connect(self._save_notif_settings)
|
||||||
|
notif_layout.addWidget(self.chk_notif_main)
|
||||||
|
|
||||||
|
self.chk_notif_start = QCheckBox("Notificar únicamente cuando INICIA un proceso de respaldo")
|
||||||
|
self.chk_notif_start.setChecked(self.config.notify_on_start)
|
||||||
|
self.chk_notif_start.stateChanged.connect(self._save_notif_settings)
|
||||||
|
notif_layout.addWidget(self.chk_notif_start)
|
||||||
|
|
||||||
|
self.chk_notif_complete = QCheckBox("Notificar únicamente cuando FINALIZA con éxito (Confirmación SHA-256)")
|
||||||
|
self.chk_notif_complete.setChecked(self.config.notify_on_complete)
|
||||||
|
self.chk_notif_complete.stateChanged.connect(self._save_notif_settings)
|
||||||
|
notif_layout.addWidget(self.chk_notif_complete)
|
||||||
|
|
||||||
|
self.chk_notif_error = QCheckBox("Notificar en caso de error o pérdida de conexión")
|
||||||
|
self.chk_notif_error.setChecked(self.config.notify_on_error)
|
||||||
|
self.chk_notif_error.stateChanged.connect(self._save_notif_settings)
|
||||||
|
notif_layout.addWidget(self.chk_notif_error)
|
||||||
|
|
||||||
|
layout.addWidget(card_notif)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# --- TAB 4: HISTORIAL ---
|
||||||
|
def _build_tab_history(self):
|
||||||
|
layout = QVBoxLayout(self.tab_history)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
lbl = QLabel("Historial de Archivos Respaldados Localmente")
|
||||||
|
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
|
||||||
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
|
self.tbl_history = QTableWidget(0, 4)
|
||||||
|
self.tbl_history.setHorizontalHeaderLabels(["Archivo", "Tamaño", "Integridad SHA-256", "Fecha de Respaldo"])
|
||||||
|
self.tbl_history.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||||
|
layout.addWidget(self.tbl_history)
|
||||||
|
|
||||||
|
self._refresh_history_table()
|
||||||
|
|
||||||
|
# --- TRAY ICON & WINDOW CLOSE BEHAVIOR ---
|
||||||
|
def _init_tray(self):
|
||||||
|
self.tray = QSystemTrayIcon(self)
|
||||||
|
self.tray.setIcon(self.app_icon)
|
||||||
|
self.tray.setToolTip(f"OnEver Drive — {self.config.client_code or 'Sin Registrar'}")
|
||||||
|
|
||||||
|
self._build_tray_menu()
|
||||||
|
self.tray.activated.connect(self._on_tray_activated)
|
||||||
|
self.tray.show()
|
||||||
|
|
||||||
|
def _build_tray_menu(self):
|
||||||
|
menu = QMenu()
|
||||||
|
client_label = self.config.client_code or "Sin Registrar"
|
||||||
|
act_title = QAction(f"OnEver Drive ({client_label})", self)
|
||||||
|
act_title.setEnabled(False)
|
||||||
|
menu.addAction(act_title)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
act_open = QAction("🖥️ Abrir Panel de Control", self)
|
||||||
|
act_open.triggered.connect(self.showNormal)
|
||||||
|
menu.addAction(act_open)
|
||||||
|
|
||||||
|
act_sync = QAction("▶ Respaldar Todo Ahora", self)
|
||||||
|
act_sync.triggered.connect(self._trigger_all_backups)
|
||||||
|
menu.addAction(act_sync)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
# Notification direct toggle in tray
|
||||||
|
self.act_tray_notif = QAction("🔔 Notificaciones Activadas" if self.config.enable_notifications else "🔕 Notificaciones Silenciadas", self)
|
||||||
|
self.act_tray_notif.triggered.connect(self._toggle_tray_notifications)
|
||||||
|
menu.addAction(self.act_tray_notif)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
act_exit = QAction("❌ Salir", self)
|
||||||
|
act_exit.triggered.connect(self._clean_exit)
|
||||||
|
menu.addAction(act_exit)
|
||||||
|
|
||||||
|
self.tray.setContextMenu(menu)
|
||||||
|
|
||||||
|
def _toggle_tray_notifications(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.config.enable_notifications = not self.config.enable_notifications
|
||||||
|
save_config(self.config)
|
||||||
|
self.chk_notif_main.setChecked(self.config.enable_notifications)
|
||||||
|
self._build_tray_menu()
|
||||||
|
|
||||||
|
def _save_notif_settings(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.config.enable_notifications = self.chk_notif_main.isChecked()
|
||||||
|
self.config.notify_on_start = self.chk_notif_start.isChecked()
|
||||||
|
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
|
||||||
|
self.config.notify_on_error = self.chk_notif_error.isChecked()
|
||||||
|
save_config(self.config)
|
||||||
|
self._build_tray_menu()
|
||||||
|
|
||||||
|
def _on_tray_activated(self, reason):
|
||||||
|
if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||||
|
self.showNormal()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
"""Minimize silently to system tray on close without showing intrusive popups."""
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def _clean_exit(self):
|
||||||
|
self.daemon.stop()
|
||||||
|
QApplication.quit()
|
||||||
|
|
||||||
|
# --- ACTIONS & NOTIFICATION TRIGGERS (Discrete: Start & Finish only) ---
|
||||||
|
def _update_header_status(self):
|
||||||
|
self.config = load_config()
|
||||||
|
if self.config.client_code:
|
||||||
|
self.lbl_status_badge.setText(f"● Conectado ({self.config.client_code})")
|
||||||
|
self.lbl_status_badge.setStyleSheet("background-color: #065F46; color: #34D399; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||||||
|
self.lbl_dash_client.setText(f"Cliente ID: {self.config.client_code} ({self.config.client_name or 'Local'})")
|
||||||
|
self.lbl_dash_server.setText(f"Servidor Proxmox: {self.config.server_url}")
|
||||||
|
self.lbl_dash_folders.setText(f"Carpetas en Monitoreo: {len(self.config.local_folders)}")
|
||||||
|
else:
|
||||||
|
self.lbl_status_badge.setText("● Sin Registrar")
|
||||||
|
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
|
||||||
|
|
||||||
|
def _show_add_folder_dialog(self):
|
||||||
|
dialog = AddFolderDialog(self)
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
new_job = dialog.get_data()
|
||||||
|
self.config = load_config()
|
||||||
|
self.config.local_folders.append(new_job)
|
||||||
|
save_config(self.config)
|
||||||
|
self._refresh_folders_table()
|
||||||
|
self._update_header_status()
|
||||||
|
QMessageBox.information(self, "Carpeta Añadida", f"La carpeta '{new_job.name}' ha sido configurada y está siendo monitoreada.")
|
||||||
|
|
||||||
|
def _refresh_folders_table(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.tbl_folders.setRowCount(len(self.config.local_folders))
|
||||||
|
for row, job in enumerate(self.config.local_folders):
|
||||||
|
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name))
|
||||||
|
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path))
|
||||||
|
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns))
|
||||||
|
self.tbl_folders.setItem(row, 3, QTableWidgetItem(f"{job.schedule_interval_minutes} min"))
|
||||||
|
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera"))
|
||||||
|
|
||||||
|
def _delete_selected_folder(self):
|
||||||
|
row = self.tbl_folders.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
QMessageBox.warning(self, "Selección", "Por favor selecciona una carpeta para eliminar.")
|
||||||
|
return
|
||||||
|
|
||||||
|
job_name = self.tbl_folders.item(row, 0).text()
|
||||||
|
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?")
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
self.config = load_config()
|
||||||
|
if row < len(self.config.local_folders):
|
||||||
|
self.config.local_folders.pop(row)
|
||||||
|
save_config(self.config)
|
||||||
|
self._refresh_folders_table()
|
||||||
|
self._update_header_status()
|
||||||
|
|
||||||
|
def _test_server_connection(self):
|
||||||
|
url = self.txt_server_url.text().strip().rstrip("/")
|
||||||
|
if not url:
|
||||||
|
QMessageBox.warning(self, "Error", "Ingresa una URL de servidor.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
with httpx.Client(timeout=5.0) as client:
|
||||||
|
r = client.get(f"{url}/health")
|
||||||
|
elapsed_ms = int((time.time() - t0) * 1000)
|
||||||
|
if r.status_code == 200:
|
||||||
|
QMessageBox.information(self, "Conexión Exitosa", f"✓ Servidor OnEver Drive alcanzable.\nLatencia: {elapsed_ms} ms\nRespuesta: {r.json()}")
|
||||||
|
else:
|
||||||
|
QMessageBox.warning(self, "Error", f"El servidor respondió con código {r.status_code}")
|
||||||
|
except Exception as ex:
|
||||||
|
QMessageBox.critical(self, "Error de Conexión", f"No se pudo contactar al servidor en {url}:\n{str(ex)}")
|
||||||
|
|
||||||
|
def _register_device_api(self):
|
||||||
|
server_url = self.txt_server_url.text().strip().rstrip("/")
|
||||||
|
code = self.txt_reg_code.text().strip().upper()
|
||||||
|
if not server_url or not code:
|
||||||
|
QMessageBox.warning(self, "Error", "Debes ingresar la URL del servidor y el código de registro.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
|
||||||
|
payload = {
|
||||||
|
"registration_code": code,
|
||||||
|
"name": hostname,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os_info": os_info,
|
||||||
|
"agent_version": "1.0.0"
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
resp = client.post(f"{server_url}/api/clients/register", json=payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
QMessageBox.warning(self, "Registro Fallido", f"El servidor denegó el registro: {resp.text}")
|
||||||
|
return
|
||||||
|
data = resp.json()
|
||||||
|
self.config.server_url = server_url
|
||||||
|
self.config.client_code = data["client_code"]
|
||||||
|
self.config.device_id = data["device_id"]
|
||||||
|
self.config.device_token = data["device_token"]
|
||||||
|
self.config.client_name = data["name"]
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
|
self._update_header_status()
|
||||||
|
self._build_tray_menu()
|
||||||
|
self.daemon.start()
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Registro Exitoso", f"¡Dispositivo vinculado con éxito!\nCliente: {data['client_code']}")
|
||||||
|
except Exception as ex:
|
||||||
|
QMessageBox.critical(self, "Error", f"Error durante el registro: {str(ex)}")
|
||||||
|
|
||||||
|
def _trigger_all_backups(self):
|
||||||
|
if not self.config.device_id:
|
||||||
|
QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.")
|
||||||
|
return
|
||||||
|
|
||||||
|
threading.Thread(target=self.daemon._run_backup_cycle, daemon=True).start()
|
||||||
|
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
|
||||||
|
|
||||||
|
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
|
||||||
|
def _on_backup_started(self, filename: str, file_size: int):
|
||||||
|
mb = file_size / (1024 * 1024)
|
||||||
|
self.lbl_transfer_info.setText(f"Iniciando respaldo de: {filename} ({mb:.2f} MB)")
|
||||||
|
|
||||||
|
# Single notification at START of backup (if enabled)
|
||||||
|
if self.config.enable_notifications and self.config.notify_on_start:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"OnEver Drive — Inicio de Respaldo",
|
||||||
|
f"Iniciando transferencia de {filename} ({mb:.2f} MB)...",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
2500
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_live_progress(self, filename: str, done: int, total: int, pct: float):
|
||||||
|
# Progress updates ONLY update the GUI progressbar silently (no popups)
|
||||||
|
self.pbar_transfer.setValue(int(pct))
|
||||||
|
self.lbl_transfer_info.setText(f"Subiendo {filename}...")
|
||||||
|
self.lbl_chunk_details.setText(f"Chunks: {done} / {total} ({pct:.1f}%) | Motor por Bloques de 4MB Activo")
|
||||||
|
|
||||||
|
def _on_backup_completed(self, filename: str, sha256: str, file_size: int):
|
||||||
|
self.pbar_transfer.setValue(100)
|
||||||
|
self.lbl_transfer_info.setText(f"✓ Backup verificado e íntegro: {filename}")
|
||||||
|
self.lbl_chunk_details.setText(f"SHA-256: {sha256[:16]}... | Tamaño: {file_size / (1024*1024):.2f} MB")
|
||||||
|
|
||||||
|
# Single notification at FINISH of backup (if enabled)
|
||||||
|
if self.config.enable_notifications and self.config.notify_on_complete:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"OnEver Drive — Respaldo Exitoso ✓",
|
||||||
|
f"{filename} respaldado y verificado en el servidor central (SHA-256).",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
3000
|
||||||
|
)
|
||||||
|
self._refresh_history_table()
|
||||||
|
self._refresh_folders_table()
|
||||||
|
|
||||||
|
def _on_backup_error(self, filename: str, err: str):
|
||||||
|
self.lbl_transfer_info.setText(f"✗ Error al respaldar {filename}")
|
||||||
|
self.lbl_chunk_details.setText(f"Detalle: {err}")
|
||||||
|
|
||||||
|
# Notification on ERROR (if enabled)
|
||||||
|
if self.config.enable_notifications and self.config.notify_on_error:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"OnEver Drive — Error en Respaldo ✗",
|
||||||
|
f"Fallo al respaldar {filename}: {err}",
|
||||||
|
QSystemTrayIcon.MessageIcon.Warning,
|
||||||
|
4000
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_daemon_status(self, status: str, message: str):
|
||||||
|
if status == "ONLINE":
|
||||||
|
self._update_header_status()
|
||||||
|
|
||||||
|
def _refresh_history_table(self):
|
||||||
|
try:
|
||||||
|
with state_db._get_conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT filepath, file_size, sha256, last_backup_time FROM completed_files ORDER BY last_backup_time DESC LIMIT 50")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
self.tbl_history.setRowCount(len(rows))
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
self.tbl_history.setItem(r_idx, 0, QTableWidgetItem(Path(row[0]).name))
|
||||||
|
self.tbl_history.setItem(r_idx, 1, QTableWidgetItem(f"{row[1] / (1024*1024):.2f} MB"))
|
||||||
|
self.tbl_history.setItem(r_idx, 2, QTableWidgetItem(row[2][:16] + "..."))
|
||||||
|
self.tbl_history.setItem(r_idx, 3, QTableWidgetItem(str(row[3])))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setQuitOnLastWindowClosed(False)
|
||||||
|
window = OnEverDriveMainWindow()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import httpx
|
||||||
|
from colorama import init, Fore, Style
|
||||||
|
|
||||||
|
# Add agent directory to sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from agent.config import load_config, save_config, AgentConfig
|
||||||
|
from agent.uploader import ChunkUploader
|
||||||
|
from agent.service import AgentDaemon
|
||||||
|
|
||||||
|
init(autoreset=True)
|
||||||
|
|
||||||
|
def print_banner():
|
||||||
|
print(Fore.CYAN + Style.BRIGHT + """
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| ONEVER DRIVE - WINDOWS AGENT CLI |
|
||||||
|
| Enterprise Resilient Chunk Backup Engine for Windows |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
""")
|
||||||
|
|
||||||
|
def cmd_register(args):
|
||||||
|
print_banner()
|
||||||
|
server_url = args.server.rstrip("/")
|
||||||
|
code = args.code.strip().upper()
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
|
||||||
|
|
||||||
|
print(Fore.YELLOW + f"Connecting to {server_url} with registration code: {code}...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"registration_code": code,
|
||||||
|
"name": args.name or hostname,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os_info": os_info,
|
||||||
|
"agent_version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
resp = client.post(f"{server_url}/api/clients/register", json=payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(Fore.RED + f"Registration failed ({resp.status_code}): {resp.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
config = load_config()
|
||||||
|
config.server_url = server_url
|
||||||
|
config.client_code = data["client_code"]
|
||||||
|
config.device_id = data["device_id"]
|
||||||
|
config.device_token = data["device_token"]
|
||||||
|
config.client_name = data["name"]
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print(Fore.GREEN + Style.BRIGHT + "\n[+] Agent registered successfully!")
|
||||||
|
print(Fore.WHITE + f" Client Code : {data['client_code']}")
|
||||||
|
print(Fore.WHITE + f" Device ID : {data['device_id']}")
|
||||||
|
print(Fore.WHITE + f" Client Name : {data['name']}")
|
||||||
|
print(Fore.CYAN + "\nYou can now start the agent daemon or run manual backups.")
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
print(Fore.RED + f"Error connecting to server: {str(ex)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def cmd_status(args):
|
||||||
|
print_banner()
|
||||||
|
config = load_config()
|
||||||
|
if not config.device_id:
|
||||||
|
print(Fore.YELLOW + "Agent is NOT registered yet. Run 'agent_cli.py register' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(Fore.GREEN + "[*] Agent Configuration:")
|
||||||
|
print(f" Server URL : {config.server_url}")
|
||||||
|
print(f" Client Code : {config.client_code}")
|
||||||
|
print(f" Device ID : {config.device_id}")
|
||||||
|
print(f" Client Name : {config.client_name}")
|
||||||
|
print(f" Chunk Size : {config.chunk_size / (1024*1024):.1f} MB")
|
||||||
|
|
||||||
|
print(Fore.CYAN + "\n[*] Testing connection to server...")
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"X-Device-Id": config.device_id,
|
||||||
|
"X-Device-Token": config.device_token
|
||||||
|
}
|
||||||
|
with httpx.Client(base_url=config.server_url, headers=headers, timeout=10.0) as client:
|
||||||
|
resp = client.get("/api/jobs/agent/assigned")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
jobs = resp.json()
|
||||||
|
print(Fore.GREEN + f" Connection OK. Assigned jobs count: {len(jobs)}")
|
||||||
|
for j in jobs:
|
||||||
|
print(Fore.WHITE + f" - [{j['job_code']}] {j['name']} ({j['source_path']} | {j['file_patterns']})")
|
||||||
|
else:
|
||||||
|
print(Fore.RED + f" Server returned {resp.status_code}: {resp.text}")
|
||||||
|
except Exception as ex:
|
||||||
|
print(Fore.RED + f" Connection failed: {str(ex)}")
|
||||||
|
|
||||||
|
def cmd_backup(args):
|
||||||
|
print_banner()
|
||||||
|
filepath = Path(args.file).resolve()
|
||||||
|
if not filepath.exists():
|
||||||
|
print(Fore.RED + f"File not found: {filepath}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
if not config.device_id:
|
||||||
|
print(Fore.RED + "Agent is not registered. Run registration first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(Fore.CYAN + f"[*] Initiating chunked backup for: {filepath.name}")
|
||||||
|
print(f" File size : {filepath.stat().st_size / (1024*1024):.2f} MB")
|
||||||
|
print(f" Chunk size : {config.chunk_size / (1024*1024):.1f} MB")
|
||||||
|
|
||||||
|
uploader = ChunkUploader(config)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def print_progress(received, total, pct):
|
||||||
|
bar_len = 30
|
||||||
|
filled = int(bar_len * (pct / 100))
|
||||||
|
bar = "#" * filled + "-" * (bar_len - filled)
|
||||||
|
sys.stdout.write(f"\r{Fore.YELLOW}Progress: [{bar}] {pct:.1f}% ({received}/{total} chunks)")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = uploader.upload_file(filepath, job_id=args.job, progress_callback=print_progress)
|
||||||
|
elapsed = max(0.01, time.time() - start_time)
|
||||||
|
mb = filepath.stat().st_size / (1024 * 1024)
|
||||||
|
speed = mb / elapsed
|
||||||
|
|
||||||
|
print(Fore.GREEN + Style.BRIGHT + f"\n\n[+] Backup Complete & Verified!")
|
||||||
|
print(Fore.WHITE + f" Remote Path : {result.get('relative_path')}")
|
||||||
|
print(Fore.WHITE + f" SHA-256 : {result.get('sha256')}")
|
||||||
|
print(Fore.WHITE + f" Transfer : {mb:.2f} MB in {elapsed:.2f}s ({speed:.2f} MB/s)")
|
||||||
|
except Exception as ex:
|
||||||
|
print(Fore.RED + f"\n[-] Backup failed: {str(ex)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def cmd_daemon(args):
|
||||||
|
print_banner()
|
||||||
|
config = load_config()
|
||||||
|
daemon = AgentDaemon(config)
|
||||||
|
daemon.start()
|
||||||
|
print(Fore.GREEN + "[*] Agent daemon running. Press Ctrl+C to stop.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
daemon.stop()
|
||||||
|
print(Fore.YELLOW + "\nAgent stopped.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="OnEver Drive Windows Agent CLI")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
# Register
|
||||||
|
p_reg = subparsers.add_parser("register", help="Register agent with central server")
|
||||||
|
p_reg.add_argument("--server", required=True, help="Server URL (e.g. http://192.168.1.100:8000)")
|
||||||
|
p_reg.add_argument("--code", required=True, help="Registration code (e.g. OED-A1B2-C3D4)")
|
||||||
|
p_reg.add_argument("--name", help="Custom name for this client machine")
|
||||||
|
p_reg.set_defaults(func=cmd_register)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
p_stat = subparsers.add_parser("status", help="Show current agent status and server connectivity")
|
||||||
|
p_stat.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
p_bak = subparsers.add_parser("backup", help="Perform manual chunked backup of a file")
|
||||||
|
p_bak.add_argument("--file", required=True, help="Path to file to back up")
|
||||||
|
p_bak.add_argument("--job", type=int, help="Optional Backup Job ID")
|
||||||
|
p_bak.set_defaults(func=cmd_backup)
|
||||||
|
|
||||||
|
# Daemon
|
||||||
|
p_daemon = subparsers.add_parser("daemon", help="Run the background worker loop in foreground")
|
||||||
|
p_daemon.set_defaults(func=cmd_daemon)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if hasattr(args, "func"):
|
||||||
|
args.func(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox, filedialog
|
||||||
|
from pathlib import Path
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Add agent root to sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from agent.config import load_config, save_config, AgentConfig
|
||||||
|
from agent.uploader import ChunkUploader
|
||||||
|
from agent.chunker import compute_file_sha256
|
||||||
|
|
||||||
|
class AgentGuiApp:
|
||||||
|
"""Lightweight Windows GUI control panel for OnEver Drive."""
|
||||||
|
|
||||||
|
def __init__(self, root: tk.Tk):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("OnEver Drive — Agente Windows")
|
||||||
|
self.root.geometry("640x520")
|
||||||
|
self.root.resizable(False, False)
|
||||||
|
|
||||||
|
# Style configuration
|
||||||
|
self.config = load_config()
|
||||||
|
self._apply_dark_theme()
|
||||||
|
|
||||||
|
# Build UI layout
|
||||||
|
self._build_header()
|
||||||
|
self._build_tabs()
|
||||||
|
|
||||||
|
self._refresh_status()
|
||||||
|
|
||||||
|
def _apply_dark_theme(self):
|
||||||
|
self.root.configure(bg="#0F172A")
|
||||||
|
style = ttk.Style()
|
||||||
|
style.theme_use("clam")
|
||||||
|
|
||||||
|
# Configure colors
|
||||||
|
style.configure("TNotebook", background="#0F172A", borderwidth=0)
|
||||||
|
style.configure("TNotebook.Tab", background="#1E293B", foreground="#94A3B8", padding=[16, 8], font=("Segoe UI", 9, "bold"))
|
||||||
|
style.map("TNotebook.Tab", background=[("selected", "#06B6D4")], foreground=[("selected", "#FFFFFF")])
|
||||||
|
|
||||||
|
style.configure("TFrame", background="#0F172A")
|
||||||
|
style.configure("Card.TFrame", background="#1E293B", relief="flat")
|
||||||
|
style.configure("TLabel", background="#0F172A", foreground="#F8FAFC", font=("Segoe UI", 9))
|
||||||
|
style.configure("Card.TLabel", background="#1E293B", foreground="#F8FAFC", font=("Segoe UI", 9))
|
||||||
|
style.configure("Dim.TLabel", background="#1E293B", foreground="#94A3B8", font=("Segoe UI", 8))
|
||||||
|
style.configure("Header.TLabel", background="#0F172A", foreground="#FFFFFF", font=("Segoe UI", 12, "bold"))
|
||||||
|
|
||||||
|
style.configure("Primary.TButton", background="#06B6D4", foreground="#FFFFFF", font=("Segoe UI", 9, "bold"), borderwidth=0, padding=6)
|
||||||
|
style.map("Primary.TButton", background=[("active", "#0891B2")])
|
||||||
|
|
||||||
|
style.configure("Secondary.TButton", background="#334155", foreground="#FFFFFF", font=("Segoe UI", 9), borderwidth=0, padding=6)
|
||||||
|
style.map("Secondary.TButton", background=[("active", "#475569")])
|
||||||
|
|
||||||
|
style.configure("TProgressbar", thickness=10, background="#06B6D4", troughcolor="#334155", borderwidth=0)
|
||||||
|
|
||||||
|
def _build_header(self):
|
||||||
|
header_frame = ttk.Frame(self.root, padding=16)
|
||||||
|
header_frame.pack(fill="x")
|
||||||
|
|
||||||
|
title_lbl = ttk.Label(header_frame, text="OnEver Drive — Agente Windows", style="Header.TLabel")
|
||||||
|
title_lbl.pack(side="left")
|
||||||
|
|
||||||
|
self.status_badge = tk.Label(header_frame, text="● En Línea", bg="#065F46", fg="#34D399", font=("Segoe UI", 8, "bold"), padx=8, pady=3)
|
||||||
|
self.status_badge.pack(side="right")
|
||||||
|
|
||||||
|
def _build_tabs(self):
|
||||||
|
self.notebook = ttk.Notebook(self.root)
|
||||||
|
self.notebook.pack(fill="both", expand=True, padx=16, pady=(0, 16))
|
||||||
|
|
||||||
|
# Tab 1: Estado y Dispositivo
|
||||||
|
self.tab_status = ttk.Frame(self.notebook, padding=16)
|
||||||
|
self.notebook.add(self.tab_status, text="Estado")
|
||||||
|
self._build_tab_status()
|
||||||
|
|
||||||
|
# Tab 2: Trabajos de Backup
|
||||||
|
self.tab_jobs = ttk.Frame(self.notebook, padding=16)
|
||||||
|
self.notebook.add(self.tab_jobs, text="Trabajos Asignados")
|
||||||
|
self._build_tab_jobs()
|
||||||
|
|
||||||
|
# Tab 3: Respaldo Manual
|
||||||
|
self.tab_manual = ttk.Frame(self.notebook, padding=16)
|
||||||
|
self.notebook.add(self.tab_manual, text="Backup Manual")
|
||||||
|
self._build_tab_manual()
|
||||||
|
|
||||||
|
# Tab 4: Registro / Configuración
|
||||||
|
self.tab_config = ttk.Frame(self.notebook, padding=16)
|
||||||
|
self.notebook.add(self.tab_config, text="Configuración")
|
||||||
|
self._build_tab_config()
|
||||||
|
|
||||||
|
def _build_tab_status(self):
|
||||||
|
card = ttk.Frame(self.tab_status, style="Card.TFrame", padding=16)
|
||||||
|
card.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
self.lbl_client_code = ttk.Label(card, text="Cliente ID: —", style="Card.TLabel", font=("Segoe UI", 10, "bold"))
|
||||||
|
self.lbl_client_code.pack(anchor="w", pady=(0, 4))
|
||||||
|
|
||||||
|
self.lbl_client_name = ttk.Label(card, text="Nombre: —", style="Dim.TLabel")
|
||||||
|
self.lbl_client_name.pack(anchor="w", pady=2)
|
||||||
|
|
||||||
|
self.lbl_server_url = ttk.Label(card, text="Servidor: —", style="Dim.TLabel")
|
||||||
|
self.lbl_server_url.pack(anchor="w", pady=2)
|
||||||
|
|
||||||
|
self.lbl_device_id = ttk.Label(card, text="Device ID: —", style="Dim.TLabel")
|
||||||
|
self.lbl_device_id.pack(anchor="w", pady=2)
|
||||||
|
|
||||||
|
ttk.Separator(card).pack(fill="x", pady=12)
|
||||||
|
|
||||||
|
ttk.Label(card, text="Motor de Transferencia:", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||||||
|
ttk.Label(card, text="• Transferencia por bloques de 4 MB\n• Reanudación automática ante microcortes\n• Verificación estricta de integridad SHA-256\n• Detección de archivos en uso (Locks de SQL Server)", style="Dim.TLabel").pack(anchor="w", pady=6)
|
||||||
|
|
||||||
|
btn_refresh = ttk.Button(card, text="Actualizar Estado", style="Secondary.TButton", command=self._refresh_status)
|
||||||
|
btn_refresh.pack(anchor="e", pady=(12, 0))
|
||||||
|
|
||||||
|
def _build_tab_jobs(self):
|
||||||
|
self.jobs_container = ttk.Frame(self.tab_jobs, style="Card.TFrame", padding=12)
|
||||||
|
self.jobs_container.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
self.jobs_list_lbl = ttk.Label(self.jobs_container, text="Cargando trabajos asignados por el servidor...", style="Dim.TLabel")
|
||||||
|
self.jobs_list_lbl.pack(anchor="w", pady=10)
|
||||||
|
|
||||||
|
def _build_tab_manual(self):
|
||||||
|
card = ttk.Frame(self.tab_manual, style="Card.TFrame", padding=16)
|
||||||
|
card.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
ttk.Label(card, text="Selecciona un archivo local para realizar un backup por chunks:", style="Card.TLabel").pack(anchor="w", pady=(0, 8))
|
||||||
|
|
||||||
|
file_frame = ttk.Frame(card, style="Card.TFrame")
|
||||||
|
file_frame.pack(fill="x", pady=4)
|
||||||
|
|
||||||
|
self.entry_file = tk.Entry(file_frame, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||||
|
self.entry_file.pack(side="left", fill="x", expand=True, ipady=6, padx=(0, 8))
|
||||||
|
|
||||||
|
btn_browse = ttk.Button(file_frame, text="Explorar...", style="Secondary.TButton", command=self._browse_file)
|
||||||
|
btn_browse.pack(side="right")
|
||||||
|
|
||||||
|
self.btn_upload = ttk.Button(card, text="Iniciar Backup Inmediato", style="Primary.TButton", command=self._start_manual_backup)
|
||||||
|
self.btn_upload.pack(fill="x", pady=16)
|
||||||
|
|
||||||
|
self.lbl_progress = ttk.Label(card, text="Estado: En espera", style="Dim.TLabel")
|
||||||
|
self.lbl_progress.pack(anchor="w", pady=(0, 4))
|
||||||
|
|
||||||
|
self.progressbar = ttk.Progressbar(card, style="TProgressbar", mode="determinate")
|
||||||
|
self.progressbar.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
def _build_tab_config(self):
|
||||||
|
card = ttk.Frame(self.tab_config, style="Card.TFrame", padding=16)
|
||||||
|
card.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
ttk.Label(card, text="Servidor Central (URL):", style="Card.TLabel").pack(anchor="w")
|
||||||
|
self.entry_server = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||||
|
self.entry_server.insert(0, self.config.server_url)
|
||||||
|
self.entry_server.pack(fill="x", ipady=6, pady=(4, 12))
|
||||||
|
|
||||||
|
ttk.Label(card, text="Código de Registro (generado en Web UI):", style="Card.TLabel").pack(anchor="w")
|
||||||
|
self.entry_reg_code = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
|
||||||
|
self.entry_reg_code.pack(fill="x", ipady=6, pady=(4, 16))
|
||||||
|
|
||||||
|
btn_register = ttk.Button(card, text="Registrar Dispositivo", style="Primary.TButton", command=self._register_device)
|
||||||
|
btn_register.pack(fill="x")
|
||||||
|
|
||||||
|
def _browse_file(self):
|
||||||
|
filename = filedialog.askopenfilename(title="Seleccionar archivo para backup", filetypes=[("Archivos SQL Server / Todos", "*.bak;*.mdf;*.*")])
|
||||||
|
if filename:
|
||||||
|
self.entry_file.delete(0, tk.END)
|
||||||
|
self.entry_file.insert(0, filename)
|
||||||
|
|
||||||
|
def _start_manual_backup(self):
|
||||||
|
filepath_str = self.entry_file.get().strip()
|
||||||
|
if not filepath_str or not os.path.exists(filepath_str):
|
||||||
|
messagebox.showerror("Error", "Por favor selecciona un archivo existente.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.config.device_id:
|
||||||
|
messagebox.showerror("Error", "El agente no está registrado contra el servidor.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.btn_upload.configure(state="disabled")
|
||||||
|
self.lbl_progress.configure(text="Iniciando subida por bloques...")
|
||||||
|
self.progressbar["value"] = 0
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
filepath = Path(filepath_str)
|
||||||
|
uploader = ChunkUploader(self.config)
|
||||||
|
|
||||||
|
def on_progress(done, total, pct):
|
||||||
|
self.root.after(0, lambda: self._update_progress_ui(done, total, pct, filepath.name))
|
||||||
|
|
||||||
|
res = uploader.upload_file(filepath, progress_callback=on_progress)
|
||||||
|
self.root.after(0, lambda: self._on_backup_success(filepath.name, res))
|
||||||
|
except Exception as ex:
|
||||||
|
self.root.after(0, lambda: self._on_backup_error(str(ex)))
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _update_progress_ui(self, done, total, pct, filename):
|
||||||
|
self.progressbar["value"] = pct
|
||||||
|
self.lbl_progress.configure(text=f"Subiendo {filename}: {done}/{total} chunks ({pct:.1f}%)")
|
||||||
|
|
||||||
|
def _on_backup_success(self, filename, res):
|
||||||
|
self.btn_upload.configure(state="normal")
|
||||||
|
self.progressbar["value"] = 100
|
||||||
|
self.lbl_progress.configure(text=f"✓ Backup completado y verificado: {filename}")
|
||||||
|
messagebox.showinfo("Éxito", f"¡Backup completado con éxito!\n\nArchivo: {filename}\nSHA-256: {res.get('sha256')}\nRuta remota: {res.get('relative_path')}")
|
||||||
|
|
||||||
|
def _on_backup_error(self, err_msg):
|
||||||
|
self.btn_upload.configure(state="normal")
|
||||||
|
self.lbl_progress.configure(text=f"✗ Error: {err_msg}")
|
||||||
|
messagebox.showerror("Error de Backup", f"Fallo al respaldar archivo:\n{err_msg}")
|
||||||
|
|
||||||
|
def _register_device(self):
|
||||||
|
server_url = self.entry_server.get().strip().rstrip("/")
|
||||||
|
code = self.entry_reg_code.get().strip().upper()
|
||||||
|
|
||||||
|
if not server_url or not code:
|
||||||
|
messagebox.showerror("Error", "Debes ingresar la URL del servidor y el código de registro.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import socket, platform
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
|
||||||
|
payload = {
|
||||||
|
"registration_code": code,
|
||||||
|
"name": hostname,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os_info": os_info,
|
||||||
|
"agent_version": "1.0.0"
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
resp = client.post(f"{server_url}/api/clients/register", json=payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
messagebox.showerror("Error de Registro", f"El servidor respondió: {resp.text}")
|
||||||
|
return
|
||||||
|
data = resp.json()
|
||||||
|
self.config.server_url = server_url
|
||||||
|
self.config.client_code = data["client_code"]
|
||||||
|
self.config.device_id = data["device_id"]
|
||||||
|
self.config.device_token = data["device_token"]
|
||||||
|
self.config.client_name = data["name"]
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
|
messagebox.showinfo("Registro Exitoso", f"¡Dispositivo registrado!\nCliente: {data['client_code']}\nID: {data['device_id']}")
|
||||||
|
self._refresh_status()
|
||||||
|
except Exception as ex:
|
||||||
|
messagebox.showerror("Error de Conexión", f"No se pudo conectar al servidor:\n{str(ex)}")
|
||||||
|
|
||||||
|
def _refresh_status(self):
|
||||||
|
self.config = load_config()
|
||||||
|
if self.config.client_code:
|
||||||
|
self.lbl_client_code.configure(text=f"Cliente ID: {self.config.client_code}")
|
||||||
|
self.lbl_client_name.configure(text=f"Nombre: {self.config.client_name or '—'}")
|
||||||
|
self.lbl_server_url.configure(text=f"Servidor: {self.config.server_url}")
|
||||||
|
self.lbl_device_id.configure(text=f"Device ID: {self.config.device_id}")
|
||||||
|
self.status_badge.configure(text="● Registrado", bg="#065F46", fg="#34D399")
|
||||||
|
self._fetch_jobs()
|
||||||
|
else:
|
||||||
|
self.lbl_client_code.configure(text="Cliente ID: No Registrado")
|
||||||
|
self.status_badge.configure(text="● Sin Registro", bg="#7F1D1D", fg="#FCA5A5")
|
||||||
|
|
||||||
|
def _fetch_jobs(self):
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
headers = {"X-Device-Id": self.config.device_id, "X-Device-Token": self.config.device_token}
|
||||||
|
with httpx.Client(base_url=self.config.server_url, headers=headers, timeout=10.0) as client:
|
||||||
|
resp = client.get("/api/jobs/agent/assigned")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
jobs = resp.json()
|
||||||
|
self.root.after(0, lambda: self._render_jobs_list(jobs))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _render_jobs_list(self, jobs):
|
||||||
|
for widget in self.jobs_container.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
ttk.Label(self.jobs_container, text="No hay trabajos programados asignados a este equipo.", style="Dim.TLabel").pack(pady=20)
|
||||||
|
return
|
||||||
|
|
||||||
|
for j in jobs:
|
||||||
|
item_frame = ttk.Frame(self.jobs_container, style="Card.TFrame", padding=8)
|
||||||
|
item_frame.pack(fill="x", pady=4)
|
||||||
|
|
||||||
|
ttk.Label(item_frame, text=f"[{j['job_code']}] {j['name']}", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||||||
|
ttk.Label(item_frame, text=f"Ruta: {j['source_path']} | Filtros: {j['file_patterns']} | Cron: {j['schedule_cron']}", style="Dim.TLabel").pack(anchor="w")
|
||||||
|
|
||||||
|
def launch_gui():
|
||||||
|
root = tk.Tk()
|
||||||
|
app = AgentGuiApp(root)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
launch_gui()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
import pystray
|
||||||
|
from pystray import MenuItem as item
|
||||||
|
|
||||||
|
# Add agent root to sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from agent.config import load_config, AgentConfig
|
||||||
|
from agent.service import AgentDaemon
|
||||||
|
from agent.uploader import ChunkUploader
|
||||||
|
from create_icons import generate_app_icons
|
||||||
|
|
||||||
|
class WindowsTrayAgent:
|
||||||
|
"""Windows System Tray (Área de Notificaciones) Application for OnEver Drive."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.daemon = AgentDaemon(self.config)
|
||||||
|
self.icon = None
|
||||||
|
self.is_paused = False
|
||||||
|
self.icon_image = self._load_icon()
|
||||||
|
|
||||||
|
def _load_icon(self) -> Image.Image:
|
||||||
|
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||||
|
png_path = assets_dir / "icon.png"
|
||||||
|
if not png_path.exists():
|
||||||
|
_, png_path = generate_app_icons()
|
||||||
|
return Image.open(png_path)
|
||||||
|
|
||||||
|
def _get_status_text(self) -> str:
|
||||||
|
if not self.config.client_code:
|
||||||
|
return "Estado: Sin Registrar"
|
||||||
|
if self.is_paused:
|
||||||
|
return "Estado: Pausado"
|
||||||
|
return f"Estado: Conectado ({self.config.client_code})"
|
||||||
|
|
||||||
|
def _toggle_pause(self, icon, item_obj):
|
||||||
|
self.is_paused = not self.is_paused
|
||||||
|
if self.is_paused:
|
||||||
|
self.daemon.stop()
|
||||||
|
self.notify("Sincronización en pausa", "El servicio de backup ha sido pausado.")
|
||||||
|
else:
|
||||||
|
self.daemon.start()
|
||||||
|
self.notify("Sincronización activa", "El servicio de backup se ha reanudado.")
|
||||||
|
|
||||||
|
def _open_gui(self, icon=None, item_obj=None):
|
||||||
|
def run_gui():
|
||||||
|
from agent_gui import launch_gui
|
||||||
|
launch_gui()
|
||||||
|
|
||||||
|
threading.Thread(target=run_gui, daemon=True).start()
|
||||||
|
|
||||||
|
def _manual_backup(self, icon=None, item_obj=None):
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
|
||||||
|
def run_picker():
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
filepath = filedialog.askopenfilename(
|
||||||
|
title="Seleccionar archivo para backup",
|
||||||
|
filetypes=[("Archivos SQL / Datos", "*.bak;*.mdf;*.*")]
|
||||||
|
)
|
||||||
|
if not filepath:
|
||||||
|
root.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.notify("Iniciando Backup", f"Preparando transferencia por chunks: {Path(filepath).name}")
|
||||||
|
|
||||||
|
def upload_worker():
|
||||||
|
try:
|
||||||
|
uploader = ChunkUploader(self.config)
|
||||||
|
res = uploader.upload_file(Path(filepath))
|
||||||
|
self.notify("Backup Completado ✓", f"{Path(filepath).name} verificado con éxito en el servidor.")
|
||||||
|
except Exception as ex:
|
||||||
|
self.notify("Error en Backup ✗", f"Fallo al subir {Path(filepath).name}: {str(ex)}")
|
||||||
|
|
||||||
|
threading.Thread(target=upload_worker, daemon=True).start()
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
threading.Thread(target=run_picker, daemon=True).start()
|
||||||
|
|
||||||
|
def notify(self, title: str, message: str):
|
||||||
|
"""Displays a native Windows Notification Balloon."""
|
||||||
|
if self.icon:
|
||||||
|
try:
|
||||||
|
self.icon.notify(message, title)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_exit(self, icon, item_obj):
|
||||||
|
self.daemon.stop()
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
def build_menu(self):
|
||||||
|
client_title = f"OnEver Drive ({self.config.client_name or 'Agente'})"
|
||||||
|
return pystray.Menu(
|
||||||
|
item(client_title, lambda: None, enabled=False),
|
||||||
|
item(lambda text: self._get_status_text(), lambda: None, enabled=False),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
item("Abrir Panel de Control...", self._open_gui, default=True),
|
||||||
|
item("Hacer Backup Manual...", self._manual_backup),
|
||||||
|
item(lambda text: "Reanudar Servicio" if self.is_paused else "Pausar Servicio", self._toggle_pause),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
item("Salir", self._on_exit)
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# Start background daemon worker
|
||||||
|
if self.config.device_id:
|
||||||
|
self.daemon.start()
|
||||||
|
|
||||||
|
# Create tray icon
|
||||||
|
self.icon = pystray.Icon(
|
||||||
|
name="OnEverDrive",
|
||||||
|
icon=self.icon_image,
|
||||||
|
title="OnEver Drive — Agente de Backup",
|
||||||
|
menu=self.build_menu()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify on startup
|
||||||
|
if self.config.client_code:
|
||||||
|
self.notify("OnEver Drive Activo", f"Agente en ejecución ({self.config.client_code})")
|
||||||
|
else:
|
||||||
|
self.notify("OnEver Drive", "Agente iniciado. Requiere registro en el servidor.")
|
||||||
|
|
||||||
|
# Run system tray event loop
|
||||||
|
self.icon.run()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
agent = WindowsTrayAgent()
|
||||||
|
agent.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def build_windows_exe():
|
||||||
|
agent_dir = Path(__file__).resolve().parent
|
||||||
|
assets_dir = agent_dir / "assets"
|
||||||
|
ico_path = assets_dir / "icon.ico"
|
||||||
|
|
||||||
|
if not ico_path.exists():
|
||||||
|
print("[*] Generating icons...")
|
||||||
|
from create_icons import generate_app_icons
|
||||||
|
generate_app_icons()
|
||||||
|
|
||||||
|
print("========================================================================")
|
||||||
|
print(" BUILDING ONEVER DRIVE MODERN PYQT6 WINDOWS EXECUTABLE (.EXE) ")
|
||||||
|
print("========================================================================")
|
||||||
|
|
||||||
|
dist_dir = agent_dir / "dist"
|
||||||
|
build_dir = agent_dir / "build"
|
||||||
|
|
||||||
|
# PyInstaller arguments for modern PyQt6 standalone agent
|
||||||
|
pyinstaller_args = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"PyInstaller",
|
||||||
|
"--noconfirm",
|
||||||
|
"--onedir",
|
||||||
|
"--windowed", # No console popup, pure GUI and tray
|
||||||
|
f"--icon={str(ico_path)}",
|
||||||
|
f"--name=OnEverDriveAgent",
|
||||||
|
f"--distpath={str(dist_dir)}",
|
||||||
|
f"--workpath={str(build_dir)}",
|
||||||
|
f"--add-data={str(assets_dir)}{os.pathsep}assets",
|
||||||
|
"--hidden-import=PyQt6",
|
||||||
|
"--hidden-import=PyQt6.QtCore",
|
||||||
|
"--hidden-import=PyQt6.QtGui",
|
||||||
|
"--hidden-import=PyQt6.QtWidgets",
|
||||||
|
"--hidden-import=httpx",
|
||||||
|
"--hidden-import=pydantic",
|
||||||
|
"--hidden-import=pydantic_settings",
|
||||||
|
"--hidden-import=schedule",
|
||||||
|
"--hidden-import=sqlite3",
|
||||||
|
str(agent_dir / "agent_app_pyqt.py")
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[*] Compiling PyQt6 directory bundle...")
|
||||||
|
subprocess.run(pyinstaller_args, check=True, cwd=str(agent_dir))
|
||||||
|
|
||||||
|
# Single-file build
|
||||||
|
standalone_args = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"PyInstaller",
|
||||||
|
"--noconfirm",
|
||||||
|
"--onefile",
|
||||||
|
"--windowed",
|
||||||
|
f"--icon={str(ico_path)}",
|
||||||
|
f"--name=OnEverDriveAgent-Standalone",
|
||||||
|
f"--distpath={str(dist_dir)}",
|
||||||
|
f"--workpath={str(build_dir)}",
|
||||||
|
f"--add-data={str(assets_dir)}{os.pathsep}assets",
|
||||||
|
"--hidden-import=PyQt6",
|
||||||
|
"--hidden-import=PyQt6.QtCore",
|
||||||
|
"--hidden-import=PyQt6.QtGui",
|
||||||
|
"--hidden-import=PyQt6.QtWidgets",
|
||||||
|
"--hidden-import=httpx",
|
||||||
|
"--hidden-import=pydantic",
|
||||||
|
"--hidden-import=pydantic_settings",
|
||||||
|
"--hidden-import=schedule",
|
||||||
|
"--hidden-import=sqlite3",
|
||||||
|
str(agent_dir / "agent_app_pyqt.py")
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[*] Compiling standalone single-file .exe...")
|
||||||
|
subprocess.run(standalone_args, check=True, cwd=str(agent_dir))
|
||||||
|
|
||||||
|
exe_path = dist_dir / "OnEverDriveAgent" / "OnEverDriveAgent.exe"
|
||||||
|
single_exe_path = dist_dir / "OnEverDriveAgent-Standalone.exe"
|
||||||
|
|
||||||
|
print("")
|
||||||
|
echo = "========================================================================"
|
||||||
|
print(echo)
|
||||||
|
print(f"[+] PyQt6 Executables built successfully:")
|
||||||
|
print(f" 1. {exe_path}")
|
||||||
|
print(f" 2. {single_exe_path}")
|
||||||
|
print(echo)
|
||||||
|
|
||||||
|
return exe_path
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_windows_exe()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from PIL import Image, ImageDraw
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def generate_app_icons():
|
||||||
|
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||||
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
size = (256, 256)
|
||||||
|
img = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw rounded shield / background
|
||||||
|
# Gradient/Cyan-Indigo background rounded rect
|
||||||
|
draw.rounded_rectangle([(16, 16), (240, 240)], radius=48, fill="#0F172A", outline="#06B6D4", width=8)
|
||||||
|
|
||||||
|
# Draw cloud / shield icon
|
||||||
|
# Cloud base
|
||||||
|
draw.ellipse([(60, 110), (140, 170)], fill="#06B6D4")
|
||||||
|
draw.ellipse([(110, 80), (190, 160)], fill="#38BDF8")
|
||||||
|
draw.ellipse([(140, 110), (200, 170)], fill="#60A5FA")
|
||||||
|
draw.rectangle([(100, 130), (170, 170)], fill="#06B6D4")
|
||||||
|
|
||||||
|
# Draw upward upload arrow inside cloud
|
||||||
|
# Arrow head
|
||||||
|
draw.polygon([(145, 110), (120, 135), (170, 135)], fill="#FFFFFF")
|
||||||
|
# Arrow body
|
||||||
|
draw.rectangle([(137, 135), (153, 160)], fill="#FFFFFF")
|
||||||
|
|
||||||
|
# Save PNG
|
||||||
|
png_path = assets_dir / "icon.png"
|
||||||
|
img.save(png_path, "PNG")
|
||||||
|
|
||||||
|
# Save ICO
|
||||||
|
ico_path = assets_dir / "icon.ico"
|
||||||
|
img.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
|
||||||
|
|
||||||
|
print(f"Icons generated at: {png_path} and {ico_path}")
|
||||||
|
return ico_path, png_path
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generate_app_icons()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
OnEver Drive — Windows Agent Service Installer
|
||||||
|
.DESCRIPTION
|
||||||
|
Installs and configures the OnEver Drive Windows Agent as a background system service.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param (
|
||||||
|
[string]$ServerUrl = "http://127.0.0.1:8000",
|
||||||
|
[string]$RegistrationCode = "",
|
||||||
|
[string]$ClientName = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " OnEver Drive — Windows Service Setup Script " -ForegroundColor Cyan
|
||||||
|
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$CurrentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$AgentDir = Split-Path -Parent $CurrentDir
|
||||||
|
$PythonExe = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
|
||||||
|
if (-not $PythonExe) {
|
||||||
|
Write-Error "Python 3.10+ was not found on PATH. Please install Python first."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[+] Detected Python executable: $PythonExe" -ForegroundColor Green
|
||||||
|
Write-Host "[+] Agent root directory: $AgentDir" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 1. If registration code provided, perform initial registration
|
||||||
|
if ($RegistrationCode -ne "") {
|
||||||
|
Write-Host "[*] Registering agent against server: $ServerUrl..." -ForegroundColor Yellow
|
||||||
|
$RegArgs = @("$AgentDir\agent_cli.py", "register", "--server", $ServerUrl, "--code", $RegistrationCode)
|
||||||
|
if ($ClientName -ne "") {
|
||||||
|
$RegArgs += @("--name", $ClientName)
|
||||||
|
}
|
||||||
|
& $PythonExe $RegArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Registration failed. Please check registration code and server connectivity."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Service definition
|
||||||
|
$ServiceName = "OnEverDriveAgent"
|
||||||
|
$ServiceDisplayName = "OnEver Drive Backup Agent Service"
|
||||||
|
$ServiceDescription = "Enterprise chunked backup and sync daemon for OnEver Drive Proxmox platform."
|
||||||
|
$ServiceBinary = "`"$PythonExe`" `"$AgentDir\agent_cli.py`" daemon"
|
||||||
|
|
||||||
|
Write-Host "[*] Registering Windows Service: $ServiceName..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Stop and remove existing service if present
|
||||||
|
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($ExistingService) {
|
||||||
|
Write-Host "[-] Stopping and removing existing service..." -ForegroundColor Yellow
|
||||||
|
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
||||||
|
sc.exe delete $ServiceName
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create service using sc.exe
|
||||||
|
sc.exe create $ServiceName binPath= $ServiceBinary start= auto DisplayName= $ServiceDisplayName
|
||||||
|
sc.exe description $ServiceName $ServiceDescription
|
||||||
|
|
||||||
|
# Configure recovery options (restart service on crash)
|
||||||
|
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000
|
||||||
|
|
||||||
|
Write-Host "[+] Service '$ServiceName' registered successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "[*] To start the service run: Start-Service $ServiceName" -ForegroundColor Cyan
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
httpx>=0.27.0
|
||||||
|
pydantic>=2.6.0
|
||||||
|
pydantic-settings>=2.2.0
|
||||||
|
schedule>=1.2.1
|
||||||
|
colorama>=0.4.6
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@echo off
|
||||||
|
title OnEver Drive - Windows Agent Launcher (PyQt6)
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo Starting OnEver Drive PyQt6 Agent in notification area...
|
||||||
|
start "" "%~dp0dist\OnEverDriveAgent\OnEverDriveAgent.exe"
|
||||||
|
exit
|
||||||
Reference in New Issue
Block a user