feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user