28295e346c
- Created a global TypeScript declaration for the txadmin interface on the Window object. - Implemented date formatting utilities using date-fns with Spanish locale support. - Added TypeScript configuration files for app, Electron, and Node environments. - Set up Vite configuration for React application.
1070 lines
45 KiB
TypeScript
1070 lines
45 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff, ClipboardList, Calculator, X, MoreVertical, Play, Pause, Power, Trash2, Unlink, QrCode, RotateCw, Edit3, Plus } from 'lucide-react';
|
|
import { auditWapServer, createWapServer, deleteWapBot, deleteWapServer, detachWapOrganizationServer, fetchWapServers, getWapBotQr, recalculateWapServerCount, restartWapBot, startWapBot, stopWapBot, updateWapServer, type WapContainerDto, type WapServerAuditResult, type WapServerDeleteResult, 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 [deleteServerTarget, setDeleteServerTarget] = useState<any | null>(null);
|
|
const [forceDeleteServer, setForceDeleteServer] = useState(false);
|
|
const [serverFormMode, setServerFormMode] = useState<'create' | 'edit'>('edit');
|
|
const [editForm, setEditForm] = useState({ name: '', ipv4: '', port: '', maxBots: '', active: true });
|
|
|
|
// Filters
|
|
const [filters, setFilters] = useState({
|
|
name: '',
|
|
description: '',
|
|
active: 'all', // all | true | false
|
|
countBotsFrom: '',
|
|
countBotsTo: ''
|
|
});
|
|
|
|
const loadServers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const payload: any = { page, limit: 10 };
|
|
if (filters.name) payload.name = filters.name;
|
|
if (filters.description) payload.description = filters.description;
|
|
if (filters.active !== 'all') payload.active = filters.active === 'true';
|
|
if (filters.countBotsFrom) payload.countBotsFrom = parseInt(filters.countBotsFrom, 10);
|
|
if (filters.countBotsTo) payload.countBotsTo = parseInt(filters.countBotsTo, 10);
|
|
|
|
const res = await fetchWapServers(payload);
|
|
setServers(res.data);
|
|
setTotalPages(res.pages);
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('Error cargando servidores WAP');
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
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);
|
|
loadServers();
|
|
};
|
|
|
|
const handleResetFilters = () => {
|
|
setFilters({
|
|
name: '',
|
|
description: '',
|
|
active: 'all',
|
|
countBotsFrom: '',
|
|
countBotsTo: ''
|
|
});
|
|
setPage(1);
|
|
};
|
|
|
|
const getServerId = (server: any) => server.id || server._id;
|
|
|
|
const runRowAction = (action: () => void) => {
|
|
setOpenActionsServerId(null);
|
|
setOpenAuditActionKey(null);
|
|
action();
|
|
};
|
|
|
|
const openEditServer = (server: any) => {
|
|
setServerFormMode('edit');
|
|
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 openCreateServer = () => {
|
|
setServerFormMode('create');
|
|
setEditingServer({});
|
|
setEditForm({ name: '', ipv4: '', port: '', maxBots: '', active: true });
|
|
};
|
|
|
|
const handleSaveServer = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!editingServer) return;
|
|
|
|
const serverId = serverFormMode === 'edit' ? getServerId(editingServer) : 'create';
|
|
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 {
|
|
if (serverFormMode === 'create') {
|
|
await createWapServer({
|
|
name: editForm.name,
|
|
ipv4: editForm.ipv4,
|
|
port,
|
|
maxBots,
|
|
active: editForm.active,
|
|
});
|
|
setActionMessage('Servidor WAP creado.');
|
|
} else {
|
|
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) => {
|
|
setForceDeleteServer(false);
|
|
setDeleteServerTarget(server);
|
|
};
|
|
|
|
const confirmDeleteServer = async () => {
|
|
if (!deleteServerTarget) return;
|
|
|
|
const server = deleteServerTarget;
|
|
const serverId = getServerId(server);
|
|
const serverLabel = server.name || serverId;
|
|
|
|
setActionServerId(serverId);
|
|
try {
|
|
const result: WapServerDeleteResult = await deleteWapServer({ serverId, force: forceDeleteServer || undefined });
|
|
const forceSummary = result.force
|
|
? ` Modo forzado: organizaciones desasociadas ${result.detachedOrganizations ?? 0}, bots runtime eliminados ${result.deletedRuntimeBots ?? 0}, bots omitidos ${result.skippedRuntimeBots ?? 0}${result.auditFailed ? `. Auditoría no disponible: ${result.auditMessage || 'sin detalle'}` : ''}.`
|
|
: '';
|
|
|
|
setActionMessage(`Servidor WAP eliminado: ${serverLabel}.${forceSummary}`);
|
|
setSelectedAuditServerId(null);
|
|
setDeleteServerTarget(null);
|
|
setForceDeleteServer(false);
|
|
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 }}>
|
|
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Servidores WAP</h1>
|
|
<button type="button" className="btn-primary" onClick={openCreateServer} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<Plus size={18} /> Nuevo servidor
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters Bar */}
|
|
<div className="glass-panel filters-bar" style={{ padding: '1.5rem', marginBottom: '1rem', flexShrink: 0 }}>
|
|
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
<div style={{ flex: '1', minWidth: '150px' }}>
|
|
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Nombre</label>
|
|
<input
|
|
type="text"
|
|
className="input-glass"
|
|
placeholder="Buscar por nombre..."
|
|
value={filters.name}
|
|
onChange={(e) => setFilters({...filters, name: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: '1', minWidth: '150px' }}>
|
|
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Descripción</label>
|
|
<input
|
|
type="text"
|
|
className="input-glass"
|
|
placeholder="Buscar por descripción..."
|
|
value={filters.description}
|
|
onChange={(e) => setFilters({...filters, description: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div style={{ width: '100px' }}>
|
|
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Bots Min</label>
|
|
<input
|
|
type="number"
|
|
className="input-glass"
|
|
placeholder="Mín..."
|
|
value={filters.countBotsFrom}
|
|
onChange={(e) => setFilters({...filters, countBotsFrom: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div style={{ width: '100px' }}>
|
|
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Bots Max</label>
|
|
<input
|
|
type="number"
|
|
className="input-glass"
|
|
placeholder="Máx..."
|
|
value={filters.countBotsTo}
|
|
onChange={(e) => setFilters({...filters, countBotsTo: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div style={{ width: '120px' }}>
|
|
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Activo</label>
|
|
<select
|
|
className="input-glass"
|
|
value={filters.active}
|
|
onChange={(e) => setFilters({...filters, active: e.target.value})}
|
|
>
|
|
<option value="all">Todos</option>
|
|
<option value="true">Sí</option>
|
|
<option value="false">No</option>
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button type="submit" className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px', height: '100%' }}>
|
|
<Search size={18} /> Buscar
|
|
</button>
|
|
<button type="button" onClick={handleResetFilters} className="btn-secondary" style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0.75rem', height: '100%' }} title="Limpiar Filtros">
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '800px' }}>
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Servidor</th>
|
|
<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={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando servidores...</td></tr>
|
|
) : servers.length === 0 ? (
|
|
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron servidores WAP</td></tr>
|
|
) : (
|
|
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)" />}
|
|
{s.name}
|
|
</div>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '4px' }}>{s.description || 'Sin descripción'}</div>
|
|
</td>
|
|
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
|
<div style={{ marginBottom: '2px' }}><span style={{ color: 'var(--text-main)' }}>IPv4:</span> {s.ipv4 || '-'}</div>
|
|
<div><span style={{ color: 'var(--text-main)' }}>IPv6:</span> {s.ipv6 || '-'}</div>
|
|
</td>
|
|
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
|
<div style={{ background: 'rgba(255,255,255,0.05)', display: 'inline-block', padding: '4px 12px', borderRadius: '12px', fontSize: '0.9rem', color: s.countBots >= s.maxBots ? 'var(--warning)' : 'var(--text-main)' }}>
|
|
<strong>{s.countBots}</strong> / {s.maxBots} bots
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px', color: s.active ? 'var(--success)' : 'var(--warning)' }}>
|
|
{s.active ? <CheckCircle size={16} /> : <XCircle size={16} />}
|
|
{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>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="pagination-bar" style={{ padding: '1rem', borderTop: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0, background: 'rgba(0,0,0,0.2)' }}>
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Página {page} de {totalPages || 1}</span>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button className="btn-secondary" disabled={page === 1} onClick={() => setPage(p => p - 1)}>Anterior</button>
|
|
<button className="btn-secondary" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Siguiente</button>
|
|
</div>
|
|
</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>{serverFormMode === 'create' ? 'Nuevo servidor' : 'Editar servidor'}</h2>
|
|
<p>{serverFormMode === 'create' ? 'Crear servidor WAP' : 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 === (serverFormMode === 'create' ? 'create' : getServerId(editingServer))}>{serverFormMode === 'create' ? 'Crear servidor' : 'Guardar cambios'}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{deleteServerTarget && (
|
|
<div className="modal-overlay" onClick={() => setDeleteServerTarget(null)}>
|
|
<div className="delete-server-modal glass-panel" onClick={(event) => event.stopPropagation()}>
|
|
<div className="audit-modal-header">
|
|
<div>
|
|
<h2>Eliminar servidor WAP</h2>
|
|
<p>{deleteServerTarget.name || getServerId(deleteServerTarget)}</p>
|
|
</div>
|
|
<button type="button" className="btn-secondary" onClick={() => setDeleteServerTarget(null)} aria-label="Cerrar confirmación">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="delete-warning">
|
|
<strong>Esta acción elimina el servidor WAP.</strong>
|
|
<p>Sin forzar, el servidor debe estar inactivo y no tener bots asociados.</p>
|
|
<p>Si activás borrado forzado, se omiten esas validaciones: las organizaciones asignadas se desasocian y los bots/contenedores runtime detectados pueden eliminarse u omitirse si no se pueden identificar con seguridad.</p>
|
|
</div>
|
|
|
|
<label className="force-delete-option">
|
|
<input type="checkbox" checked={forceDeleteServer} onChange={(event) => setForceDeleteServer(event.target.checked)} />
|
|
<span>Forzar eliminación y desasociar organizaciones/bots aunque el servidor esté activo.</span>
|
|
</label>
|
|
|
|
<div className="edit-server-actions">
|
|
<button type="button" className="btn-secondary" onClick={() => setDeleteServerTarget(null)}>Cancelar</button>
|
|
<button type="button" className="btn-danger" disabled={actionServerId === getServerId(deleteServerTarget)} onClick={confirmDeleteServer}>Eliminar servidor</button>
|
|
</div>
|
|
</div>
|
|
</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;
|
|
top: 0;
|
|
z-index: 10;
|
|
background: rgba(10, 10, 15, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
border-bottom: 1px solid var(--glass-border);
|
|
transition: background 0.2s;
|
|
}
|
|
.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;
|
|
}
|
|
.delete-server-modal {
|
|
width: min(560px, 96vw);
|
|
padding: 1.25rem;
|
|
}
|
|
.delete-warning {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border: 1px solid rgba(239, 68, 68, 0.32);
|
|
border-radius: 14px;
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: var(--text-main);
|
|
}
|
|
.delete-warning p {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
}
|
|
.force-delete-option {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
margin-top: 1rem;
|
|
padding: 0.9rem;
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 12px;
|
|
color: #fecaca;
|
|
font-weight: 700;
|
|
line-height: 1.35;
|
|
cursor: pointer;
|
|
}
|
|
.force-delete-option input {
|
|
margin-top: 0.2rem;
|
|
}
|
|
.btn-danger {
|
|
background: rgba(239, 68, 68, 0.9);
|
|
color: #ffffff;
|
|
border: 0;
|
|
border-radius: 10px;
|
|
padding: 0.75rem 1rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
.btn-danger:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.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>
|
|
);
|
|
};
|