From 7badc34816df288055f0a75404bc61b8eafe1016 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Mon, 17 Aug 2026 00:00:03 -0300 Subject: [PATCH] feat: ordenar backups por cliente, generar codigos unicos de cliente UUID y soporte para compilar agente desde la web --- backend/app/api/clients.py | 92 +++++++++++++++- frontend/src/pages/ClientsView.tsx | 168 ++++++++++++++++++++++++++++- frontend/src/pages/RestoreView.tsx | 8 +- frontend/src/services/api.ts | 23 ++++ 4 files changed, 280 insertions(+), 11 deletions(-) diff --git a/backend/app/api/clients.py b/backend/app/api/clients.py index c343eed..deebcec 100644 --- a/backend/app/api/clients.py +++ b/backend/app/api/clients.py @@ -1,7 +1,11 @@ import uuid +import sys +import subprocess +from pathlib import Path from datetime import datetime, timedelta, timezone from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc @@ -99,10 +103,8 @@ async def register_client( client.last_seen_at = now client.is_active = True else: - # Create a new Client - max_id_res = await db.execute(select(func.max(Client.id))) - max_id = max_id_res.scalar() or 0 - client_code = f"CLIENT-{max_id + 1:04d}" + # Create a new Client with a unique generated client_code + client_code = f"CLI-{uuid.uuid4().hex[:8].upper()}" # Load default client quota from settings from app.services.settings_service import get_setting @@ -356,3 +358,83 @@ async def delete_client( ) return {"message": f"Client {client.client_code} deleted"} + +# Global in-memory build state +BUILD_STATE = { + "status": "IDLE", # IDLE, BUILDING, SUCCESS, FAILED + "started_at": None, + "finished_at": None, + "error_message": None +} + +def run_pyinstaller_build(): + global BUILD_STATE + BUILD_STATE["status"] = "BUILDING" + BUILD_STATE["started_at"] = datetime.now(timezone.utc).isoformat() + BUILD_STATE["error_message"] = None + + try: + # Cwd is workspace root + base_dir = Path(__file__).resolve().parent.parent.parent.parent + script_path = base_dir / "windows-agent" / "build_exe.py" + venv_python = base_dir / "backend" / "venv" / "Scripts" / "python.exe" + if not venv_python.exists(): + venv_python = sys.executable + + print(f"Starting PyInstaller compilation: {venv_python} {script_path}") + result = subprocess.run( + [str(venv_python), str(script_path)], + capture_output=True, + text=True, + cwd=str(base_dir / "windows-agent") + ) + if result.returncode == 0: + BUILD_STATE["status"] = "SUCCESS" + else: + BUILD_STATE["status"] = "FAILED" + BUILD_STATE["error_message"] = result.stderr or result.stdout + except Exception as e: + BUILD_STATE["status"] = "FAILED" + BUILD_STATE["error_message"] = str(e) + finally: + BUILD_STATE["finished_at"] = datetime.now(timezone.utc).isoformat() + +@router.post("/build-agent") +async def trigger_build_agent( + background_tasks: BackgroundTasks, + admin_user: User = Depends(require_admin) +): + global BUILD_STATE + if BUILD_STATE["status"] == "BUILDING": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A compilation is already in progress." + ) + + background_tasks.add_task(run_pyinstaller_build) + return {"message": "Compilation started in the background."} + +@router.get("/build-agent/status") +async def get_build_agent_status( + current_user: User = Depends(get_current_user) +): + return BUILD_STATE + +@router.get("/download-agent") +async def download_agent( + current_user: User = Depends(get_current_user) +): + base_dir = Path(__file__).resolve().parent.parent.parent.parent + exe_path = base_dir / "windows-agent" / "dist" / "OnEverDriveAgent-Standalone.exe" + + if not exe_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Agent executable not found. Please compile it first." + ) + + return FileResponse( + path=str(exe_path), + filename="OnEverDriveAgent-Standalone.exe", + media_type="application/octet-stream" + ) diff --git a/frontend/src/pages/ClientsView.tsx b/frontend/src/pages/ClientsView.tsx index e94d83c..c59471d 100644 --- a/frontend/src/pages/ClientsView.tsx +++ b/frontend/src/pages/ClientsView.tsx @@ -10,7 +10,8 @@ import { Server as ServerIcon, X, Edit2, - RefreshCw + RefreshCw, + Cpu } from 'lucide-react'; import { ClientItem, api } from '../services/api'; @@ -30,6 +31,65 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) const [isCopied, setIsCopied] = useState(false); const [loading, setLoading] = useState(false); + // Agent Compilation states + const [showBuildModal, setShowBuildModal] = useState(false); + const [buildStatus, setBuildStatus] = useState<{ + status: string; + started_at: string | null; + finished_at: string | null; + error_message: string | null; + }>({ status: 'IDLE', started_at: null, finished_at: null, error_message: null }); + const [buildLoading, setBuildLoading] = useState(false); + + const pollBuildStatus = async () => { + try { + const statusData = await api.getAgentBuildStatus(); + setBuildStatus(statusData); + return statusData.status; + } catch (err) { + console.error("Error polling build status:", err); + return 'FAILED'; + } + }; + + React.useEffect(() => { + let intervalId: any; + if (showBuildModal) { + pollBuildStatus(); + intervalId = setInterval(async () => { + const statusStr = await pollBuildStatus(); + if (statusStr !== 'BUILDING') { + clearInterval(intervalId); + } + }, 2000); + } + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [showBuildModal]); + + const handleStartBuild = async () => { + setBuildLoading(true); + try { + await api.buildAgent(); + setBuildStatus(prev => ({ ...prev, status: 'BUILDING' })); + // Trigger status fetch + pollBuildStatus(); + } catch (err: any) { + alert(`Error iniciando compilación: ${err.message}`); + } finally { + setBuildLoading(false); + } + }; + + const handleDownloadAgent = async () => { + try { + await api.downloadAgent(); + } catch (err: any) { + alert(`Error descargando el agente: ${err.message}`); + } + }; + const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -134,10 +194,16 @@ export const ClientsView: React.FC = ({ clients, onRefresh }) Administración centralizada de agentes Windows 10, 11 y Windows Server

- +
+ + +
@@ -415,6 +481,98 @@ export const ClientsView: React.FC = ({ clients, onRefresh })
)} + + {showBuildModal && ( +
+
+
+

+ + Generación del Cliente de Distribución +

+ +
+ +
+

+ Compila de forma centralizada y empaqueta el agente de Windows como un ejecutable independiente (.EXE) + listo para ser distribuido e instalado en clientes fuera de la red local (Internet). +

+ +
+
+ Estado del Compilador: + + {buildStatus.status === 'IDLE' ? 'Listo / Inactivo' : + buildStatus.status === 'BUILDING' ? 'Compilando...' : + buildStatus.status === 'SUCCESS' ? 'Compilado con éxito' : 'Fallido'} + +
+ + {buildStatus.started_at && ( +
+ Comenzó: {new Date(buildStatus.started_at).toLocaleString()} +
+ )} + + {buildStatus.finished_at && ( +
+ Finalizó: {new Date(buildStatus.finished_at).toLocaleString()} +
+ )} + + {buildStatus.error_message && ( +
+ {buildStatus.error_message} +
+ )} +
+
+ +
+ + + {buildStatus.status === 'SUCCESS' && ( + + )} + + {buildStatus.status !== 'BUILDING' && ( + + )} + + {buildStatus.status === 'BUILDING' && ( + + )} +
+
+
+ )} ); }; diff --git a/frontend/src/pages/RestoreView.tsx b/frontend/src/pages/RestoreView.tsx index 3a7f10c..65a9520 100644 --- a/frontend/src/pages/RestoreView.tsx +++ b/frontend/src/pages/RestoreView.tsx @@ -191,7 +191,13 @@ export const RestoreView: React.FC = ({ clients }) => { - {filteredBackups.map((backup) => ( + {[...filteredBackups].sort((a, b) => { + const clientA = getClientName(a.client_id).toLowerCase(); + const clientB = getClientName(b.client_id).toLowerCase(); + if (clientA < clientB) return -1; + if (clientA > clientB) return 1; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }).map((backup) => (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d5a146f..aaf3f10 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -205,6 +205,29 @@ export const api = { request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }), deleteClient: (clientId: number) => request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }), + buildAgent: () => + request<{ message: string }>('/clients/build-agent', { method: 'POST' }), + getAgentBuildStatus: () => + request<{ status: string; started_at: string | null; finished_at: string | null; error_message: string | null }>('/clients/build-agent/status'), + downloadAgent: async () => { + const token = getAuthToken(); + const response = await fetch(`${API_BASE}/clients/download-agent`, { + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Network error' })); + throw new Error(errorData.detail || `Download failed with status ${response.status}`); + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'OnEverDriveAgent-Standalone.exe'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) => request(`/clients/${clientId}`, { method: 'PATCH',