feat: ordenar backups por cliente, generar codigos unicos de cliente UUID y soporte para compilar agente desde la web

This commit is contained in:
Carlos Tello
2026-08-17 00:00:03 -03:00
parent bdce625767
commit 7badc34816
4 changed files with 280 additions and 11 deletions
+87 -5
View File
@@ -1,7 +1,11 @@
import uuid import uuid
import sys
import subprocess
from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import List, Optional 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.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
@@ -99,10 +103,8 @@ async def register_client(
client.last_seen_at = now client.last_seen_at = now
client.is_active = True client.is_active = True
else: else:
# Create a new Client # Create a new Client with a unique generated client_code
max_id_res = await db.execute(select(func.max(Client.id))) client_code = f"CLI-{uuid.uuid4().hex[:8].upper()}"
max_id = max_id_res.scalar() or 0
client_code = f"CLIENT-{max_id + 1:04d}"
# Load default client quota from settings # Load default client quota from settings
from app.services.settings_service import get_setting from app.services.settings_service import get_setting
@@ -356,3 +358,83 @@ async def delete_client(
) )
return {"message": f"Client {client.client_code} deleted"} 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"
)
+163 -5
View File
@@ -10,7 +10,8 @@ import {
Server as ServerIcon, Server as ServerIcon,
X, X,
Edit2, Edit2,
RefreshCw RefreshCw,
Cpu
} from 'lucide-react'; } from 'lucide-react';
import { ClientItem, api } from '../services/api'; import { ClientItem, api } from '../services/api';
@@ -30,6 +31,65 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [loading, setLoading] = 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) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -134,10 +194,16 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
Administración centralizada de agentes Windows 10, 11 y Windows Server Administración centralizada de agentes Windows 10, 11 y Windows Server
</p> </p>
</div> </div>
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}> <div style={{ display: 'flex', gap: '12px' }}>
<Plus size={16} /> <button className="btn btn-secondary" onClick={() => setShowBuildModal(true)}>
Registrar Nuevo Cliente <Cpu size={16} style={{ marginRight: '6px' }} />
</button> Generar Agente (.exe)
</button>
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
<Plus size={16} />
Registrar Nuevo Cliente
</button>
</div>
</div> </div>
<div className="glass-card"> <div className="glass-card">
@@ -415,6 +481,98 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</div> </div>
</div> </div>
)} )}
{showBuildModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '560px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cpu size={18} color="var(--accent-cyan)" />
Generación del Cliente de Distribución
</h4>
<button className="modal-close" onClick={() => setShowBuildModal(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ padding: '16px 0', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', lineHeight: '1.5' }}>
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).
</p>
<div className="glass-card" style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.86rem', fontWeight: 600 }}>Estado del Compilador:</span>
<span className={`badge ${
buildStatus.status === 'IDLE' ? '' :
buildStatus.status === 'BUILDING' ? 'badge-syncing' :
buildStatus.status === 'SUCCESS' ? 'badge-online' : 'badge-offline'
}`} style={{ textTransform: 'uppercase' }}>
{buildStatus.status === 'IDLE' ? 'Listo / Inactivo' :
buildStatus.status === 'BUILDING' ? 'Compilando...' :
buildStatus.status === 'SUCCESS' ? 'Compilado con éxito' : 'Fallido'}
</span>
</div>
{buildStatus.started_at && (
<div style={{ fontSize: '0.76rem', color: 'var(--text-dim)' }}>
Comenzó: {new Date(buildStatus.started_at).toLocaleString()}
</div>
)}
{buildStatus.finished_at && (
<div style={{ fontSize: '0.76rem', color: 'var(--text-dim)' }}>
Finalizó: {new Date(buildStatus.finished_at).toLocaleString()}
</div>
)}
{buildStatus.error_message && (
<div style={{
maxHeight: '150px',
overflowY: 'auto',
backgroundColor: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '6px',
padding: '10px',
fontFamily: 'var(--font-mono)',
fontSize: '0.74rem',
color: '#fca5a5',
whiteSpace: 'pre-wrap'
}}>
{buildStatus.error_message}
</div>
)}
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button className="btn btn-secondary" onClick={() => setShowBuildModal(false)}>
Cerrar
</button>
{buildStatus.status === 'SUCCESS' && (
<button className="btn btn-primary" onClick={handleDownloadAgent}>
Descargar Instalador (.exe)
</button>
)}
{buildStatus.status !== 'BUILDING' && (
<button className="btn btn-primary" onClick={handleStartBuild} disabled={buildLoading}>
{buildLoading ? 'Iniciando...' : buildStatus.status === 'SUCCESS' ? 'Volver a Compilar' : 'Compilar Agente'}
</button>
)}
{buildStatus.status === 'BUILDING' && (
<button className="btn btn-primary" disabled style={{ opacity: 0.7 }}>
<RefreshCw className="animate-spin" size={14} style={{ marginRight: '6px' }} />
Compilando con PyInstaller...
</button>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+7 -1
View File
@@ -191,7 +191,13 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{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) => (
<tr key={backup.id}> <tr key={backup.id}>
<td> <td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+23
View File
@@ -205,6 +205,29 @@ export const api = {
request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }), request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }),
deleteClient: (clientId: number) => deleteClient: (clientId: number) =>
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }), 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 }) => updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) =>
request<ClientItem>(`/clients/${clientId}`, { request<ClientItem>(`/clients/${clientId}`, {
method: 'PATCH', method: 'PATCH',