421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
Plus,
|
|
HardDrive,
|
|
Copy,
|
|
Check,
|
|
ShieldAlert,
|
|
Trash2,
|
|
Laptop,
|
|
Server as ServerIcon,
|
|
X,
|
|
Edit2,
|
|
RefreshCw
|
|
} 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 [showEditModal, setShowEditModal] = useState(false);
|
|
const [editingClient, setEditingClient] = useState<ClientItem | null>(null);
|
|
const [editAlias, setEditAlias] = useState('');
|
|
const [editQuotaGb, setEditQuotaGb] = useState<number>(100);
|
|
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 handleReRegister = async (client: ClientItem) => {
|
|
if (confirm(`¿Estás seguro de que deseas volver a generar el código de vinculación para el cliente "${client.name}"?\n\nEsto revocará de inmediato los tokens de acceso actuales del equipo.`)) {
|
|
setLoading(true);
|
|
try {
|
|
const codeData = await api.generateReRegisterCode(client.id);
|
|
setGeneratedCode(codeData);
|
|
setShowModal(true);
|
|
} catch (err: any) {
|
|
alert(`Error al generar código de re-vinculación: ${err.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleOpenEditModal = (client: ClientItem) => {
|
|
setEditingClient(client);
|
|
setEditAlias(client.alias || '');
|
|
setEditQuotaGb(Math.round(client.storage_quota_bytes / (1024 * 1024 * 1024)));
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const handleSaveEdit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!editingClient) return;
|
|
setLoading(true);
|
|
try {
|
|
const quotaBytes = editQuotaGb * 1024 * 1024 * 1024;
|
|
await api.updateClient(editingClient.id, {
|
|
alias: editAlias.trim(),
|
|
storage_quota_bytes: quotaBytes
|
|
});
|
|
setShowEditModal(false);
|
|
onRefresh();
|
|
} catch (err: any) {
|
|
alert(`Error al actualizar cliente: ${err.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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>Ubicación / Alias</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(--accent-cyan)', fontWeight: 600 }}>
|
|
{client.alias || '—'}
|
|
</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' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
<div>{formatBytes(client.storage_used_bytes)}</div>
|
|
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
|
de {formatBytes(client.storage_quota_bytes)}
|
|
</div>
|
|
</div>
|
|
</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={() => handleOpenEditModal(client)}
|
|
title="Editar Cliente (Alias / Cuota)"
|
|
>
|
|
<Edit2 size={14} />
|
|
</button>
|
|
<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-secondary"
|
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
|
onClick={() => handleReRegister(client)}
|
|
title="Re-vincular Agente (Generar nuevo código)"
|
|
>
|
|
<RefreshCw size={14} color="var(--accent-cyan)" />
|
|
Re-vincular
|
|
</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={9} 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>
|
|
)}
|
|
{/* Edit Client Modal */}
|
|
{showEditModal && editingClient && (
|
|
<div className="modal-backdrop">
|
|
<div className="modal-card">
|
|
<div className="modal-header">
|
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Editar Cliente — {editingClient.client_code}</h3>
|
|
<button
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
|
onClick={() => setShowEditModal(false)}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSaveEdit}>
|
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
|
<label>Nombre del Equipo (Solo lectura):</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
disabled
|
|
value={editingClient.name}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
|
<label>Alias / Ubicación (ej. "CLUB REGATAS"):</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="Sin ubicación"
|
|
value={editAlias}
|
|
onChange={(e) => setEditAlias(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
|
<label>Cuota de Almacenamiento Asignada (GB):</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
className="form-input"
|
|
value={editQuotaGb}
|
|
onChange={(e) => setEditQuotaGb(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowEditModal(false)}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
|
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|