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
- { setShowModal(true); setGeneratedCode(null); }}>
-
- Registrar Nuevo Cliente
-
+
+
setShowBuildModal(true)}>
+
+ Generar Agente (.exe)
+
+
{ setShowModal(true); setGeneratedCode(null); }}>
+
+ Registrar Nuevo Cliente
+
+
@@ -415,6 +481,98 @@ export const ClientsView: React.FC = ({ clients, onRefresh })
)}
+
+ {showBuildModal && (
+
+
+
+
+
+ Generación del Cliente de Distribución
+
+ setShowBuildModal(false)}>
+
+
+
+
+
+
+ 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}
+
+ )}
+
+
+
+
+ setShowBuildModal(false)}>
+ Cerrar
+
+
+ {buildStatus.status === 'SUCCESS' && (
+
+ Descargar Instalador (.exe)
+
+ )}
+
+ {buildStatus.status !== 'BUILDING' && (
+
+ {buildLoading ? 'Iniciando...' : buildStatus.status === 'SUCCESS' ? 'Volver a Compilar' : 'Compilar Agente'}
+
+ )}
+
+ {buildStatus.status === 'BUILDING' && (
+
+
+ Compilando con PyInstaller...
+
+ )}
+
+
+
+ )}
);
};
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',