feat: ordenar backups por cliente, generar codigos unicos de cliente UUID y soporte para compilar agente desde la web
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,11 +194,17 @@ 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>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowBuildModal(true)}>
|
||||||
|
<Cpu size={16} style={{ marginRight: '6px' }} />
|
||||||
|
Generar Agente (.exe)
|
||||||
|
</button>
|
||||||
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
|
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Registrar Nuevo Cliente
|
Registrar Nuevo Cliente
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="glass-card">
|
<div className="glass-card">
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user