feat: implement full CRUD and administrative management endpoints for WAP servers in the sysadmin API
This commit is contained in:
@@ -174,6 +174,130 @@ export const fetchWapServers = async (filters: any) => {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type WapServer = {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
ipv4: string;
|
||||
ipv6?: string;
|
||||
countBots: number;
|
||||
maxBots: number;
|
||||
port: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type UpdateWapServerPayload = {
|
||||
serverId: string;
|
||||
name?: string;
|
||||
ipv4?: string;
|
||||
port?: number;
|
||||
maxBots?: number;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const updateWapServer = async (data: UpdateWapServerPayload): Promise<WapServer> => {
|
||||
const res = await sysadminApi.post('/wap/update', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type WapAssignedOrganization = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type WapContainerDto = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
organizationId?: string;
|
||||
image?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
ports?: unknown[];
|
||||
created?: number;
|
||||
};
|
||||
|
||||
export type WapServerAuditResult = {
|
||||
serverId: string;
|
||||
assignedOrganizations: WapAssignedOrganization[];
|
||||
detectedBots: WapContainerDto[];
|
||||
validBots: WapContainerDto[];
|
||||
ghostBots: WapContainerDto[];
|
||||
missingBots: WapAssignedOrganization[];
|
||||
expectedCountBots: number;
|
||||
storedCountBots: number;
|
||||
runtimeCountBots: number;
|
||||
countMismatch: boolean;
|
||||
};
|
||||
|
||||
export type WapServerRecalculateCountResult = {
|
||||
serverId: string;
|
||||
before: number;
|
||||
after: number;
|
||||
};
|
||||
|
||||
export type WapServerDeleteResult = {
|
||||
serverId: string;
|
||||
deleted: true;
|
||||
};
|
||||
|
||||
export type WapServerOrganizationActionPayload = {
|
||||
serverId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type WapServerQrResult = WapServerOrganizationActionPayload & {
|
||||
qr: string;
|
||||
};
|
||||
|
||||
export type WapServerDetachResult = WapServerOrganizationActionPayload & {
|
||||
botDeleted: boolean;
|
||||
botAlreadyMissing: boolean;
|
||||
before: number;
|
||||
after: number;
|
||||
};
|
||||
|
||||
export const auditWapServer = async (serverId: string): Promise<WapServerAuditResult> => {
|
||||
const res = await sysadminApi.post('/wap/audit', { serverId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const recalculateWapServerCount = async (serverId: string): Promise<WapServerRecalculateCountResult> => {
|
||||
const res = await sysadminApi.post('/wap/recalculate-count', { serverId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteWapServer = async (serverId: string): Promise<WapServerDeleteResult> => {
|
||||
const res = await sysadminApi.post('/wap/delete', { serverId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const startWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
|
||||
await sysadminApi.post('/wap/bot/start', data);
|
||||
};
|
||||
|
||||
export const stopWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
|
||||
await sysadminApi.post('/wap/bot/stop', data);
|
||||
};
|
||||
|
||||
export const restartWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
|
||||
await sysadminApi.post('/wap/bot/restart', data);
|
||||
};
|
||||
|
||||
export const getWapBotQr = async (data: WapServerOrganizationActionPayload): Promise<WapServerQrResult> => {
|
||||
const res = await sysadminApi.post('/wap/bot/qr', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
|
||||
await sysadminApi.post('/wap/bot/delete', data);
|
||||
};
|
||||
|
||||
export const detachWapOrganizationServer = async (data: WapServerOrganizationActionPayload): Promise<WapServerDetachResult> => {
|
||||
const res = await sysadminApi.post('/wap/bot/detach', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// Companies API Calls
|
||||
export const fetchCompanies = async (filters: any) => {
|
||||
const res = await sysadminApi.post('/companies/paginate', filters);
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff } from 'lucide-react';
|
||||
import { fetchWapServers } from '../api/sysadmin';
|
||||
import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff, ClipboardList, Calculator, X, MoreVertical, Play, Pause, Power, Trash2, Unlink, QrCode, RotateCw, Edit3 } from 'lucide-react';
|
||||
import { auditWapServer, deleteWapBot, deleteWapServer, detachWapOrganizationServer, fetchWapServers, getWapBotQr, recalculateWapServerCount, restartWapBot, startWapBot, stopWapBot, updateWapServer, type WapContainerDto, type WapServerAuditResult, type WapServerQrResult, type WapServerRecalculateCountResult } from '../api/sysadmin';
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
const responseData = (error as any)?.response?.data;
|
||||
|
||||
if (typeof responseData === 'string') {
|
||||
return responseData;
|
||||
}
|
||||
|
||||
const responseError = responseData?.error;
|
||||
|
||||
return responseData?.desc || responseData?.message || responseError?.desc || responseError?.message || responseError || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
export const WapServersPage = () => {
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [auditByServer, setAuditByServer] = useState<Record<string, WapServerAuditResult>>({});
|
||||
const [selectedAuditServerId, setSelectedAuditServerId] = useState<string | null>(null);
|
||||
const [auditTab, setAuditTab] = useState<'assigned' | 'ghosts' | 'missing'>('assigned');
|
||||
const [recalculateResult, setRecalculateResult] = useState<WapServerRecalculateCountResult | null>(null);
|
||||
const [qrResult, setQrResult] = useState<WapServerQrResult | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [actionServerId, setActionServerId] = useState<string | null>(null);
|
||||
const [openActionsServerId, setOpenActionsServerId] = useState<string | null>(null);
|
||||
const [openAuditActionKey, setOpenAuditActionKey] = useState<string | null>(null);
|
||||
const [editingServer, setEditingServer] = useState<any | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', ipv4: '', port: '', maxBots: '', active: true });
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -42,6 +65,18 @@ export const WapServersPage = () => {
|
||||
loadServers();
|
||||
}, [page, filters.active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openActionsServerId && !openAuditActionKey) return;
|
||||
|
||||
const closeActionsMenu = () => {
|
||||
setOpenActionsServerId(null);
|
||||
setOpenAuditActionKey(null);
|
||||
};
|
||||
document.addEventListener('click', closeActionsMenu);
|
||||
|
||||
return () => document.removeEventListener('click', closeActionsMenu);
|
||||
}, [openActionsServerId, openAuditActionKey]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
@@ -59,12 +94,190 @@ export const WapServersPage = () => {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getServerId = (server: any) => server.id || server._id;
|
||||
|
||||
const runRowAction = (action: () => void) => {
|
||||
setOpenActionsServerId(null);
|
||||
setOpenAuditActionKey(null);
|
||||
action();
|
||||
};
|
||||
|
||||
const openEditServer = (server: any) => {
|
||||
setEditingServer(server);
|
||||
setEditForm({
|
||||
name: server.name || '',
|
||||
ipv4: server.ipv4 || '',
|
||||
port: server.port === undefined || server.port === null ? '' : String(server.port),
|
||||
maxBots: server.maxBots === undefined || server.maxBots === null ? '' : String(server.maxBots),
|
||||
active: Boolean(server.active),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveServer = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editingServer) return;
|
||||
|
||||
const serverId = getServerId(editingServer);
|
||||
const port = Number(editForm.port);
|
||||
const maxBots = Number(editForm.maxBots);
|
||||
|
||||
if (!Number.isFinite(port) || port < 0 || !Number.isFinite(maxBots) || maxBots < 0) {
|
||||
alert('Puerto y cantidad máxima de bots deben ser números no negativos.');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionServerId(serverId);
|
||||
try {
|
||||
await updateWapServer({
|
||||
serverId,
|
||||
name: editForm.name,
|
||||
ipv4: editForm.ipv4,
|
||||
port,
|
||||
maxBots,
|
||||
active: editForm.active,
|
||||
});
|
||||
setActionMessage('Servidor WAP actualizado.');
|
||||
setEditingServer(null);
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(getErrorMessage(e, 'Error actualizando servidor WAP'));
|
||||
}
|
||||
setActionServerId(null);
|
||||
};
|
||||
|
||||
const refreshSelectedAudit = async (serverId: string) => {
|
||||
const audit = await auditWapServer(serverId);
|
||||
setAuditByServer(prev => ({ ...prev, [serverId]: audit }));
|
||||
};
|
||||
|
||||
const handleAudit = async (serverId: string) => {
|
||||
setActionServerId(serverId);
|
||||
try {
|
||||
const audit = await auditWapServer(serverId);
|
||||
setAuditByServer(prev => ({ ...prev, [serverId]: audit }));
|
||||
setAuditTab('assigned');
|
||||
setSelectedAuditServerId(serverId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(getErrorMessage(e, 'Error auditando servidor WAP'));
|
||||
}
|
||||
setActionServerId(null);
|
||||
};
|
||||
|
||||
const handleRecalculateCount = async (serverId: string) => {
|
||||
setActionServerId(serverId);
|
||||
try {
|
||||
const result = await recalculateWapServerCount(serverId);
|
||||
setRecalculateResult(result);
|
||||
setSelectedAuditServerId(null);
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(getErrorMessage(e, 'Error recalculando contador WAP'));
|
||||
}
|
||||
setActionServerId(null);
|
||||
};
|
||||
|
||||
const handleDeleteServer = async (server: any) => {
|
||||
const serverId = getServerId(server);
|
||||
const serverLabel = server.name || serverId;
|
||||
|
||||
if (!window.confirm(`Confirmá eliminar el servidor WAP ${serverLabel}. Debe estar inactivo y sin bots asociados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionServerId(serverId);
|
||||
try {
|
||||
await deleteWapServer(serverId);
|
||||
setActionMessage(`Servidor WAP eliminado: ${serverLabel}.`);
|
||||
setSelectedAuditServerId(null);
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(getErrorMessage(e, 'Error eliminando servidor WAP'));
|
||||
}
|
||||
setActionServerId(null);
|
||||
};
|
||||
|
||||
const handleBotAction = async (
|
||||
serverId: string,
|
||||
organizationId: string,
|
||||
action: 'start' | 'pause' | 'restart' | 'qr' | 'poweroff' | 'delete' | 'detach'
|
||||
) => {
|
||||
const labels = {
|
||||
start: 'iniciar el bot',
|
||||
pause: 'pausar el bot',
|
||||
restart: 'reiniciar el bot',
|
||||
qr: 'obtener QR',
|
||||
poweroff: 'apagar el bot',
|
||||
delete: 'eliminar el bot',
|
||||
detach: 'desasociar el servidor'
|
||||
};
|
||||
const destructive = action === 'poweroff' || action === 'delete' || action === 'detach';
|
||||
|
||||
if (destructive && !window.confirm(`Confirmá ${labels[action]} para la organización ${organizationId}.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionServerId(serverId);
|
||||
try {
|
||||
const payload = { serverId, organizationId };
|
||||
|
||||
if (action === 'start') await startWapBot(payload);
|
||||
if (action === 'pause' || action === 'poweroff') await stopWapBot(payload);
|
||||
if (action === 'restart') await restartWapBot(payload);
|
||||
if (action === 'qr') setQrResult(await getWapBotQr(payload));
|
||||
if (action === 'delete') await deleteWapBot(payload);
|
||||
if (action === 'detach') {
|
||||
const result = await detachWapOrganizationServer(payload);
|
||||
const botMessage = result.botAlreadyMissing ? 'El bot ya no existía en el servidor.' : 'Bot eliminado del servidor.';
|
||||
setActionMessage(`Servidor desasociado. ${botMessage} Contador: ${result.before} -> ${result.after}.`);
|
||||
await loadServers();
|
||||
}
|
||||
|
||||
if (action !== 'qr' && action !== 'detach') {
|
||||
setActionMessage(`Acción completada: ${labels[action]}.`);
|
||||
}
|
||||
|
||||
await refreshSelectedAudit(serverId);
|
||||
if (action === 'delete') await loadServers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(getErrorMessage(e, `Error al ${labels[action]}`));
|
||||
}
|
||||
setActionServerId(null);
|
||||
};
|
||||
|
||||
const renderAuditActionsMenu = (key: string, serverId: string, organizationId: string, includeBotActions: boolean, includeDetach: boolean) => (
|
||||
<div className="actions-menu-wrapper audit-actions-wrapper" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="icon-btn" title="Acciones" aria-label={`Acciones para organización ${organizationId}`} aria-haspopup="menu" aria-expanded={openAuditActionKey === key} disabled={actionServerId === serverId} onClick={() => setOpenAuditActionKey(openAuditActionKey === key ? null : key)}>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{openAuditActionKey === key && (
|
||||
<div className="actions-menu audit-row-menu" role="menu">
|
||||
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'start'))}><Play size={16} /> Reanudar bot</button>}
|
||||
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'pause'))}><Pause size={16} /> Pausar</button>}
|
||||
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'restart'))}><RotateCw size={16} /> Reiniciar</button>}
|
||||
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'qr'))}><QrCode size={16} /> Obtener QR</button>}
|
||||
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'poweroff'))}><Power size={16} /> Apagar</button>}
|
||||
{includeBotActions && <button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'delete'))}><Trash2 size={16} /> Eliminar bot</button>}
|
||||
{includeDetach && <button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'detach'))}><Unlink size={16} /> Desasociar servidor</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filters.name === '' && filters.description === '' && filters.active === 'all' && filters.countBotsFrom === '' && filters.countBotsTo === '') {
|
||||
loadServers();
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const selectedAudit = selectedAuditServerId ? auditByServer[selectedAuditServerId] : null;
|
||||
const selectedAuditServer = selectedAuditServerId ? servers.find(s => getServerId(s) === selectedAuditServerId) : null;
|
||||
const recalculateServer = recalculateResult ? servers.find(s => getServerId(s) === recalculateResult.serverId) : null;
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
|
||||
@@ -147,16 +360,22 @@ export const WapServersPage = () => {
|
||||
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Dirección IP</th>
|
||||
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Capacidad</th>
|
||||
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Estado</th>
|
||||
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Diagnóstico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center' }}>Cargando servidores...</td></tr>
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando servidores...</td></tr>
|
||||
) : servers.length === 0 ? (
|
||||
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron servidores WAP</td></tr>
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron servidores WAP</td></tr>
|
||||
) : (
|
||||
servers.map(s => (
|
||||
<tr key={s._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
|
||||
servers.map(s => {
|
||||
const serverId = getServerId(s);
|
||||
const isRunningAction = actionServerId === serverId;
|
||||
|
||||
return (
|
||||
<React.Fragment key={serverId}>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<div style={{ fontWeight: '600', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{s.active ? <Server size={16} color="var(--primary)" /> : <ServerOff size={16} color="var(--text-muted)" />}
|
||||
@@ -179,8 +398,40 @@ export const WapServersPage = () => {
|
||||
{s.active ? 'Activo' : 'Inactivo'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Acciones"
|
||||
aria-label={`Acciones para ${s.name || serverId}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openActionsServerId === serverId}
|
||||
disabled={isRunningAction}
|
||||
onClick={() => setOpenActionsServerId(openActionsServerId === serverId ? null : serverId)}
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
{openActionsServerId === serverId && (
|
||||
<div className="actions-menu" role="menu">
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openEditServer(s))}>
|
||||
<Edit3 size={16} /> Editar
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleAudit(serverId))}>
|
||||
<ClipboardList size={16} /> Auditar
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleRecalculateCount(serverId))}>
|
||||
<Calculator size={16} /> Recalcular contador
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDeleteServer(s))}>
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</React.Fragment>
|
||||
);})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -196,6 +447,223 @@ export const WapServersPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAudit && (
|
||||
<div className="modal-overlay" onClick={() => setSelectedAuditServerId(null)}>
|
||||
<div className="audit-modal glass-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="audit-modal-header">
|
||||
<div>
|
||||
<h2>Diagnóstico WAP</h2>
|
||||
<p>{selectedAuditServer?.name || selectedAudit.serverId}</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={() => setSelectedAuditServerId(null)} aria-label="Cerrar diagnóstico">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-panel">
|
||||
<div className="audit-summary">
|
||||
<span>Stored: <strong>{selectedAudit.storedCountBots}</strong></span>
|
||||
<span>Expected: <strong>{selectedAudit.expectedCountBots}</strong></span>
|
||||
<span>Runtime: <strong>{selectedAudit.runtimeCountBots}</strong></span>
|
||||
<span style={{ color: selectedAudit.countMismatch ? 'var(--warning)' : 'var(--success)' }}>
|
||||
{selectedAudit.countMismatch ? 'Mismatch' : 'OK'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="audit-tabs" role="tablist" aria-label="Resultados del diagnóstico WAP">
|
||||
<button type="button" role="tab" className={auditTab === 'assigned' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('assigned')}>
|
||||
Organizaciones asignadas ({selectedAudit.assignedOrganizations.length})
|
||||
</button>
|
||||
<button type="button" role="tab" className={auditTab === 'ghosts' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('ghosts')}>
|
||||
Bots Fantasmas ({selectedAudit.ghostBots.length})
|
||||
</button>
|
||||
<button type="button" role="tab" className={auditTab === 'missing' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('missing')}>
|
||||
Organizaciones sin bots ({selectedAudit.missingBots.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-tab-panel">
|
||||
{auditTab === 'assigned' && (
|
||||
selectedAudit.assignedOrganizations.length === 0
|
||||
? <p>Sin organizaciones asignadas.</p>
|
||||
: <table className="audit-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organización</th>
|
||||
<th>ID</th>
|
||||
<th>Estado del bot</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedAudit.assignedOrganizations.map(org => {
|
||||
const bot = selectedAudit.validBots.find((item: WapContainerDto) => item.organizationId === org.id);
|
||||
return (
|
||||
<tr key={org.id}>
|
||||
<td>{org.name || 'Sin nombre'}</td>
|
||||
<td><small>{org.id}</small></td>
|
||||
<td>{bot?.status || bot?.state || 'Sin bot detectado'}</td>
|
||||
<td>{renderAuditActionsMenu(`assigned-${org.id}`, selectedAudit.serverId, org.id, true, true)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{auditTab === 'ghosts' && (
|
||||
selectedAudit.ghostBots.length === 0
|
||||
? <p>Sin bots fantasma.</p>
|
||||
: <table className="audit-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bot</th>
|
||||
<th>Organización</th>
|
||||
<th>Estado</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedAudit.ghostBots.map((bot: WapContainerDto) => (
|
||||
<tr key={bot.id || bot.name}>
|
||||
<td>{bot.name || bot.id || 'Sin nombre'}</td>
|
||||
<td><small>{bot.organizationId || 'organizationId no detectado'}</small></td>
|
||||
<td>{bot.status || bot.state || '-'}</td>
|
||||
<td>{bot.organizationId ? renderAuditActionsMenu(`ghost-${bot.organizationId}-${bot.id || bot.name}`, selectedAudit.serverId, bot.organizationId, true, false) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{auditTab === 'missing' && (
|
||||
selectedAudit.missingBots.length === 0
|
||||
? <p>Sin organizaciones sin bot.</p>
|
||||
: <table className="audit-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organización</th>
|
||||
<th>ID</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedAudit.missingBots.map(org => (
|
||||
<tr key={org.id}>
|
||||
<td>{org.name || 'Sin nombre'}</td>
|
||||
<td><small>{org.id}</small></td>
|
||||
<td>{renderAuditActionsMenu(`missing-${org.id}`, selectedAudit.serverId, org.id, false, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingServer && (
|
||||
<div className="modal-overlay" onClick={() => setEditingServer(null)}>
|
||||
<form className="edit-server-modal glass-panel" onSubmit={handleSaveServer} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="audit-modal-header">
|
||||
<div>
|
||||
<h2>Editar servidor</h2>
|
||||
<p>{editingServer.name || getServerId(editingServer)}</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary" onClick={() => setEditingServer(null)} aria-label="Cerrar edición">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-server-grid">
|
||||
<label>
|
||||
<span>Nombre</span>
|
||||
<input className="input-glass" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>URL</span>
|
||||
<input className="input-glass" value={editForm.ipv4} onChange={(e) => setEditForm({ ...editForm, ipv4: e.target.value })} placeholder="http://localhost:3005/" required />
|
||||
<small style={{ color: 'var(--text-muted)' }}>Usá solo el origen HTTP(S), sin path, query ni credenciales.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Puerto</span>
|
||||
<input className="input-glass" type="number" min="0" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: e.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Cantidad máxima de bots</span>
|
||||
<input className="input-glass" type="number" min="0" value={editForm.maxBots} onChange={(e) => setEditForm({ ...editForm, maxBots: e.target.value })} required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" className={editForm.active ? 'status-toggle active' : 'status-toggle'} onClick={() => setEditForm({ ...editForm, active: !editForm.active })}>
|
||||
{editForm.active ? <CheckCircle size={16} /> : <XCircle size={16} />}
|
||||
{editForm.active ? 'Activo' : 'Inactivo'}
|
||||
</button>
|
||||
|
||||
<div className="edit-server-actions">
|
||||
<button type="button" className="btn-secondary" onClick={() => setEditingServer(null)}>Cancelar</button>
|
||||
<button type="submit" className="btn-primary" disabled={actionServerId === getServerId(editingServer)}>Guardar cambios</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recalculateResult && (
|
||||
<div className="modal-overlay" onClick={() => setRecalculateResult(null)}>
|
||||
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="audit-modal-header">
|
||||
<div>
|
||||
<h2>Contador recalculado</h2>
|
||||
<p>{recalculateServer?.name || recalculateResult.serverId}</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={() => setRecalculateResult(null)} aria-label="Cerrar resultado">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="recalculate-summary">
|
||||
<span>Antes: <strong>{recalculateResult.before}</strong></span>
|
||||
<span>Después: <strong>{recalculateResult.after}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrResult && (
|
||||
<div className="modal-overlay" onClick={() => setQrResult(null)}>
|
||||
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="audit-modal-header">
|
||||
<div>
|
||||
<h2>QR del bot</h2>
|
||||
<p>{qrResult.organizationId}</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={() => setQrResult(null)} aria-label="Cerrar QR">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="qr-result">
|
||||
<img src={`data:image/png;base64,${qrResult.qr}`} alt={`QR para ${qrResult.organizationId}`} />
|
||||
<textarea readOnly value={qrResult.qr} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionMessage && (
|
||||
<div className="modal-overlay" onClick={() => setActionMessage(null)}>
|
||||
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="audit-modal-header">
|
||||
<div>
|
||||
<h2>Acción completada</h2>
|
||||
<p>{actionMessage}</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={() => setActionMessage(null)} aria-label="Cerrar resultado">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
@@ -209,6 +677,280 @@ export const WapServersPage = () => {
|
||||
.table-row-hover:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.icon-btn {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--text-muted);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.actions-menu-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
.actions-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 220px;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.actions-menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #f8fafc;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.actions-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
.actions-menu-item.danger {
|
||||
color: #fecaca;
|
||||
}
|
||||
.actions-menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
.audit-actions-wrapper {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.audit-row-menu {
|
||||
z-index: 50;
|
||||
}
|
||||
.audit-panel {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: auto;
|
||||
}
|
||||
.audit-modal {
|
||||
width: min(1100px, 96vw);
|
||||
max-height: 86vh;
|
||||
overflow: visible;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.recalculate-modal {
|
||||
width: min(520px, 96vw);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.edit-server-modal {
|
||||
width: min(560px, 96vw);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.edit-server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.edit-server-grid label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--warning);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status-toggle.active {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
.edit-server-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.audit-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.audit-modal-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.audit-modal-header p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.recalculate-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.qr-result {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.qr-result img {
|
||||
max-width: 260px;
|
||||
width: 100%;
|
||||
justify-self: center;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.qr-result textarea {
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.audit-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.audit-grid h3 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.audit-grid p {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.audit-grid small {
|
||||
display: block;
|
||||
opacity: 0.75;
|
||||
word-break: break-all;
|
||||
}
|
||||
.audit-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.audit-tab {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.audit-tab:hover,
|
||||
.audit-tab.active {
|
||||
color: var(--text-main);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
.audit-tab-panel p {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.audit-tab-panel small {
|
||||
display: block;
|
||||
opacity: 0.75;
|
||||
word-break: break-all;
|
||||
}
|
||||
.audit-result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
.audit-result-table th {
|
||||
padding: 0.85rem 1rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.audit-result-table td {
|
||||
padding: 0.85rem 1rem;
|
||||
color: var(--text-main);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.audit-result-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.audit-result-table small {
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.edit-server-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user