- Agregar botón de cancelar que aparece durante el proceso de verificación por ping - Permitir a los usuarios detener la verificación manteniendo resultados parciales - Mejorar responsividad móvil para alineación de checkboxes - Actualizar documentación y versión a 1.3.2 Características: - Botón de cancelar dinámico con feedback visual - Preserva IPs ya verificadas al cancelar - Se oculta automáticamente al completar o cancelar - Útil para modo de verificación "Todas las IPs" Archivos modificados: - public.php: UI y lógica del botón de cancelar - CHANGELOG.md: entrada v1.3.2 - README.md: documentación de función de cancelar - manifest.json: actualización de versión a 1.3.2
1231 lines
43 KiB
PHP
1231 lines
43 KiB
PHP
<?php
|
|
|
|
// Configurar manejo de errores para devolver JSON
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0); // No mostrar errores en HTML
|
|
|
|
// Aumentar tiempo de ejecución para verificación por ping (puede tardar varios minutos)
|
|
set_time_limit(300); // 5 minutos
|
|
|
|
// Handler global de errores
|
|
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
|
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
|
|
});
|
|
|
|
// Handler global de excepciones no capturadas
|
|
set_exception_handler(function($exception) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'Error fatal: ' . $exception->getMessage(),
|
|
'file' => $exception->getFile(),
|
|
'line' => $exception->getLine()
|
|
]);
|
|
exit;
|
|
});
|
|
|
|
chdir(__DIR__);
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
require_once __DIR__ . '/src/IpSearchService.php';
|
|
require_once __DIR__ . '/src/PingService.php';
|
|
require_once __DIR__ . '/src/ApiHandlers.php';
|
|
|
|
use Ubnt\UcrmPluginSdk\Service\PluginLogManager;
|
|
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
|
|
use SiipAvailableIps\IpSearchService;
|
|
use SiipAvailableIps\PingService;
|
|
|
|
// Get UCRM log manager
|
|
$log = PluginLogManager::create();
|
|
|
|
// Log TODAS las peticiones
|
|
$log->appendLog('=== NUEVA PETICIÓN ===' );
|
|
$log->appendLog('Método: ' . $_SERVER['REQUEST_METHOD']);
|
|
$log->appendLog('POST data: ' . json_encode($_POST));
|
|
$log->appendLog('GET data: ' . json_encode($_GET));
|
|
$log->appendLog('Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'no definido'));
|
|
$log->appendLog('User Agent: ' . ($_SERVER['HTTP_USER_AGENT'] ?? 'no definido'));
|
|
|
|
// ============================================================================
|
|
// API REST - Manejar peticiones JSON (Postman, Webhooks, etc.)
|
|
// ============================================================================
|
|
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
|
$isJsonRequest = stripos($contentType, 'application/json') !== false;
|
|
|
|
if ($isJsonRequest && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$log->appendLog('>>> Petición API JSON detectada');
|
|
|
|
$rawInput = file_get_contents('php://input');
|
|
$log->appendLog('Raw input length: ' . strlen($rawInput) . ' bytes');
|
|
|
|
$jsonData = json_decode($rawInput, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$log->appendLog('ERROR: JSON inválido - ' . json_last_error_msg());
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Invalid JSON',
|
|
'message' => json_last_error_msg()
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$log->appendLog('JSON parseado correctamente: ' . json_encode($jsonData));
|
|
|
|
// Procesar petición API
|
|
handleApiRequest($jsonData, $log);
|
|
exit;
|
|
}
|
|
|
|
// ============================================================================
|
|
// FRONTEND HTML - Manejar peticiones AJAX del formulario
|
|
// ============================================================================
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'search') {
|
|
$log->appendLog('>>> Entrando al handler de búsqueda AJAX');
|
|
|
|
try {
|
|
// Obtener configuración del plugin desde data/config.json
|
|
$configManager = PluginConfigManager::create();
|
|
$config = $configManager->loadConfig();
|
|
|
|
$log->appendLog('Configuración cargada: ' . json_encode([
|
|
'ipserver' => $config['ipserver'] ?? 'NO CONFIGURADO',
|
|
'hasUnmsToken' => !empty($config['unmsApiToken']),
|
|
'hasApiToken' => !empty($config['apitoken'])
|
|
]));
|
|
|
|
// Validar que exista la configuración necesaria
|
|
if (empty($config['ipserver'])) {
|
|
$log->appendLog('ERROR: No se ha configurado ipserver');
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'El plugin no está configurado correctamente. Falta la dirección del servidor.'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
if (empty($config['unmsApiToken'])) {
|
|
$log->appendLog('ERROR: No se ha configurado unmsApiToken');
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'El plugin no está configurado correctamente. Falta el token de API de UNMS.'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$segmento = $_POST['segment'] ?? '';
|
|
// NOTA: Ignoramos verify_ping aquí porque ahora se hace progresivamente desde el frontend
|
|
// Pero lo logueamos para saber la intención del usuario
|
|
$verifyPingIntention = isset($_POST['verify_ping']) && $_POST['verify_ping'] === 'true';
|
|
|
|
$log->appendLog("Buscando IPs en segmento: $segmento (Búsqueda inicial rápida)");
|
|
|
|
// URL de la API de UISP - Usar HTTPS
|
|
|
|
// URL de la API de UISP - Usar HTTPS
|
|
$apiUrl = "https://{$config['ipserver']}/nms/api/v2.1/devices/ips?suspended=false&management=true&includeObsolete=true";
|
|
$apiToken = $config['unmsApiToken'];
|
|
|
|
$log->appendLog("URL de API: $apiUrl");
|
|
|
|
// Crear instancia del servicio y buscar IPs (SIN ping, el ping se hace después)
|
|
$ipService = new IpSearchService($apiUrl, $apiToken, $log);
|
|
$resultado = $ipService->buscarIpsDisponibles($segmento, false);
|
|
|
|
$log->appendLog('Resultado de búsqueda: ' . json_encode([
|
|
'success' => $resultado['success'],
|
|
'ipsDisponibles' => count($resultado['data'] ?? []),
|
|
'ipsEnUso' => count($resultado['used'] ?? [])
|
|
]));
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode($resultado);
|
|
|
|
} catch (Exception $e) {
|
|
$log->appendLog('EXCEPCIÓN en búsqueda de IPs: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'Error al procesar la solicitud: ' . $e->getMessage(),
|
|
'error_file' => $e->getFile(),
|
|
'error_line' => $e->getLine()
|
|
]);
|
|
} catch (Error $e) {
|
|
$log->appendLog('ERROR FATAL en búsqueda de IPs: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'Error fatal: ' . $e->getMessage(),
|
|
'error_file' => $e->getFile(),
|
|
'error_line' => $e->getLine()
|
|
]);
|
|
}
|
|
|
|
$log->appendLog('<<< Finalizando handler de búsqueda AJAX');
|
|
exit;
|
|
} else if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_batch') {
|
|
$log->appendLog('>>> Entrando al handler de verificación por lotes (verify_batch)');
|
|
|
|
try {
|
|
$ips = $_POST['ips'] ?? [];
|
|
if (!is_array($ips)) {
|
|
$ips = json_decode($ips, true);
|
|
}
|
|
|
|
if (empty($ips)) {
|
|
echo json_encode(['success' => true, 'results' => []]);
|
|
exit;
|
|
}
|
|
|
|
$log->appendLog('Verificando lote de ' . count($ips) . ' IPs');
|
|
|
|
// Crear instancia de PingService
|
|
$pingService = new PingService($log, 1, count($ips));
|
|
|
|
if (!$pingService->isAvailable()) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'message' => 'Comando ping no disponible'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Hacer ping
|
|
$pingResults = $pingService->pingMultipleIps($ips);
|
|
$processed = $pingService->processPingResults($pingResults);
|
|
|
|
// Retornar resultados
|
|
// responding = IPs que responden (ocupadas/conflicto)
|
|
// not_responding = IPs que no responden (disponibles)
|
|
echo json_encode([
|
|
'success' => true,
|
|
'responding' => $processed['responding'],
|
|
'not_responding' => $processed['not_responding']
|
|
]);
|
|
|
|
} catch (Throwable $e) {
|
|
$log->appendLog('ERROR en verify_batch: ' . $e->getMessage());
|
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
|
} catch (Exception $e) {
|
|
$log->appendLog('ERROR en verify_batch: ' . $e->getMessage());
|
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$log->appendLog('Petición POST recibida pero sin action=search o action no válida');
|
|
$log->appendLog('Action recibida: ' . ($_POST['action'] ?? 'NO DEFINIDA'));
|
|
}
|
|
|
|
// Log de acceso público
|
|
$log->appendLog('Acceso a la interfaz pública de búsqueda de IPs');
|
|
|
|
// Cargar configuración para verificar si ping está habilitado
|
|
$configManager = PluginConfigManager::create();
|
|
$config = $configManager->loadConfig();
|
|
$pingEnabled = !empty($config['enablePingVerification']) && ($config['enablePingVerification'] === true || $config['enablePingVerification'] === '1');
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Buscador de IPs Disponibles - SIIP</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
--success-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
|
--dark-bg: #0f0f23;
|
|
--card-bg: rgba(255, 255, 255, 0.05);
|
|
--card-border: rgba(255, 255, 255, 0.1);
|
|
--text-primary: #ffffff;
|
|
--text-secondary: #a0aec0;
|
|
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
--shadow-xl: 0 25px 80px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
background: var(--dark-bg);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
background-image:
|
|
radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.15) 0%, transparent 50%),
|
|
radial-gradient(circle at 80% 80%, rgba(245, 87, 108, 0.15) 0%, transparent 50%);
|
|
background-attachment: fixed;
|
|
}
|
|
|
|
.container {
|
|
width: 100%;
|
|
max-width: 1200px;
|
|
animation: fadeInUp 0.6s ease-out;
|
|
}
|
|
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(30px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.logo {
|
|
width: 80px;
|
|
height: 80px;
|
|
margin: 0 auto 20px;
|
|
background: var(--primary-gradient);
|
|
border-radius: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 40px;
|
|
box-shadow: var(--shadow-lg);
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% {
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
transform: scale(1.05);
|
|
}
|
|
}
|
|
|
|
h1 {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #667eea 0%, #f5576c 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-secondary);
|
|
font-size: 1.1rem;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 24px;
|
|
padding: 40px;
|
|
box-shadow: var(--shadow-xl);
|
|
margin-bottom: 30px;
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
}
|
|
|
|
.card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.search-form {
|
|
display: flex;
|
|
gap: 15px;
|
|
margin-bottom: 30px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.input-group {
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 500;
|
|
font-size: 0.9rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.input-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.input-prefix {
|
|
position: absolute;
|
|
left: 20px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
pointer-events: none;
|
|
}
|
|
|
|
input[type="text"] {
|
|
width: 100%;
|
|
padding: 16px 20px 16px 90px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 2px solid var(--card-border);
|
|
border-radius: 12px;
|
|
color: var(--text-primary);
|
|
font-size: 1rem;
|
|
font-family: 'Inter', sans-serif;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
input[type="text"]:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.btn {
|
|
padding: 16px 32px;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-family: 'Inter', sans-serif;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary-gradient);
|
|
color: white;
|
|
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn-primary:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.btn-cancel {
|
|
background: rgba(245, 87, 108, 0.2);
|
|
border: 1px solid rgba(245, 87, 108, 0.4);
|
|
color: #f5576c;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.btn-cancel:hover {
|
|
background: rgba(245, 87, 108, 0.3);
|
|
border-color: rgba(245, 87, 108, 0.6);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.loading {
|
|
display: none;
|
|
text-align: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.loading.active {
|
|
display: block;
|
|
}
|
|
|
|
.spinner {
|
|
width: 50px;
|
|
height: 50px;
|
|
margin: 0 auto 15px;
|
|
border: 4px solid rgba(255, 255, 255, 0.1);
|
|
border-top-color: #667eea;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.results {
|
|
display: none;
|
|
}
|
|
|
|
.results.active {
|
|
display: block;
|
|
animation: fadeInUp 0.5s ease-out;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
background: var(--success-gradient);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--card-border);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
thead {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
th {
|
|
padding: 16px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
td {
|
|
padding: 16px;
|
|
border-top: 1px solid var(--card-border);
|
|
}
|
|
|
|
tbody tr {
|
|
transition: background 0.2s ease;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.ip-address {
|
|
font-family: 'Courier New', monospace;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
color: #4facfe;
|
|
}
|
|
|
|
.btn-copy {
|
|
padding: 8px 16px;
|
|
background: rgba(102, 126, 234, 0.2);
|
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
|
border-radius: 8px;
|
|
color: #667eea;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.btn-copy:hover {
|
|
background: rgba(102, 126, 234, 0.3);
|
|
border-color: rgba(102, 126, 234, 0.5);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.btn-copy.copied {
|
|
background: rgba(79, 172, 254, 0.2);
|
|
border-color: rgba(79, 172, 254, 0.3);
|
|
color: #4facfe;
|
|
}
|
|
|
|
.alert {
|
|
padding: 16px 20px;
|
|
border-radius: 12px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
animation: fadeInUp 0.4s ease-out;
|
|
}
|
|
|
|
.alert-error {
|
|
background: rgba(245, 87, 108, 0.1);
|
|
border: 1px solid rgba(245, 87, 108, 0.3);
|
|
color: #f5576c;
|
|
}
|
|
|
|
.alert-success {
|
|
background: rgba(79, 172, 254, 0.1);
|
|
border: 1px solid rgba(79, 172, 254, 0.3);
|
|
color: #4facfe;
|
|
}
|
|
|
|
.ip-type-badge {
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
display: inline-block;
|
|
text-align: center;
|
|
}
|
|
|
|
.ip-type-admin {
|
|
background: rgba(245, 87, 108, 0.2);
|
|
border: 1px solid rgba(245, 87, 108, 0.4);
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.ip-type-client {
|
|
background: rgba(79, 209, 197, 0.2);
|
|
border: 1px solid rgba(79, 209, 197, 0.4);
|
|
color: #4fd1c5;
|
|
}
|
|
|
|
.footer {
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
margin-top: 40px;
|
|
}
|
|
|
|
.footer a {
|
|
color: #667eea;
|
|
text-decoration: none;
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
.footer a:hover {
|
|
color: #f5576c;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
h1 {
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.card {
|
|
padding: 25px;
|
|
}
|
|
|
|
.search-form {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.stats {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.status-pending {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.status-verifying {
|
|
background: rgba(102, 126, 234, 0.2);
|
|
color: #667eea;
|
|
animation: pulse 1.5s infinite;
|
|
}
|
|
|
|
.status-available {
|
|
background: rgba(79, 209, 197, 0.2);
|
|
color: #4fd1c5;
|
|
border: 1px solid rgba(79, 209, 197, 0.4);
|
|
}
|
|
|
|
.status-conflict {
|
|
background: rgba(245, 87, 108, 0.2);
|
|
color: #ff6b6b;
|
|
border: 1px solid rgba(245, 87, 108, 0.4);
|
|
}
|
|
|
|
.status-unknown {
|
|
background: rgba(160, 174, 192, 0.2);
|
|
color: #a0aec0;
|
|
border: 1px solid rgba(160, 174, 192, 0.4);
|
|
}
|
|
|
|
tr.hidden-row {
|
|
display: none;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
margin-bottom: 0;
|
|
padding-top: 28px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.checkbox-label {
|
|
padding-top: 0;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.input-group {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="logo">🌐</div>
|
|
<h1>Buscador de IPs Disponibles</h1>
|
|
<p class="subtitle">Sistema de gestión de direcciones IP para UISP</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<form class="search-form" id="searchForm">
|
|
<div class="input-group">
|
|
<label for="segment">Segmento de Red</label>
|
|
<div class="input-wrapper">
|
|
<span class="input-prefix">172.16.</span>
|
|
<input
|
|
type="text"
|
|
id="segment"
|
|
name="segment"
|
|
placeholder="0-255"
|
|
required
|
|
pattern="[0-9]{1,3}"
|
|
maxlength="3"
|
|
>
|
|
</div>
|
|
</div>
|
|
<?php if ($pingEnabled): ?>
|
|
<div class="input-group" style="flex: 0 0 auto; min-width: auto;">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="verifyPing" name="verify_ping" style="width: auto; padding: 0; margin: 0;" checked>
|
|
<span style="color: var(--text-secondary); font-size: 0.9rem;">🔍 Verificar con ping</span>
|
|
</label>
|
|
</div>
|
|
<div class="input-group" style="flex: 0 0 auto; min-width: auto;">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="hideAdmin" name="hide_admin" style="width: auto; padding: 0; margin: 0;">
|
|
<span style="color: var(--text-secondary); font-size: 0.9rem;">👁️ Ocultar Admin IPs</span>
|
|
</label>
|
|
</div>
|
|
<div class="input-group" id="pingLimitContainer" style="flex: 0 0 auto; min-width: 150px;">
|
|
<label for="pingLimit">Límite de IPs</label>
|
|
<select id="pingLimit" name="ping_limit" style="width: 100%; padding: 16px; background: rgba(255, 255, 255, 0.05); border: 2px solid var(--card-border); border-radius: 12px; color: var(--text-primary); font-size: 1rem;">
|
|
<option value="0">Todas (Lento)</option>
|
|
<option value="5">5 IPs (Rápido)</option>
|
|
<option value="10">10 IPs</option>
|
|
<option value="20">20 IPs</option>
|
|
</select>
|
|
</div>
|
|
<?php endif; ?>
|
|
<button type="submit" class="btn btn-primary" id="searchBtn">
|
|
<span>🔍</span>
|
|
<span>Buscar IPs</span>
|
|
</button>
|
|
</form>
|
|
|
|
<button id="cancelBtn" class="btn btn-cancel" style="display: none;">
|
|
🛑 Cancelar Verificación
|
|
</button>
|
|
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
<p id="loadingMessage">Consultando IPs disponibles...</p>
|
|
<p id="loadingPingWarning" style="display: none; color: var(--text-secondary); font-size: 0.9rem; margin-top: 10px;">
|
|
⏱️ Verificación por ping habilitada. Esto puede tardar varios minutos...
|
|
</p>
|
|
</div>
|
|
|
|
<div id="errorContainer"></div>
|
|
|
|
<div class="results" id="results">
|
|
<div class="stats">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="availableCount">0</div>
|
|
<div class="stat-label">IPs Disponibles</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="usedCount">0</div>
|
|
<div class="stat-label">IPs en Uso</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="segmentDisplay">-</div>
|
|
<div class="stat-label">Segmento</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Dirección IP</th>
|
|
<th>Tipo de IP</th>
|
|
<th>Estado</th>
|
|
<th>Acción</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ipTableBody">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Desarrollado por <a href="https://siip.mx" target="_blank">SIIP Internet</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const searchForm = document.getElementById('searchForm');
|
|
const searchBtn = document.getElementById('searchBtn');
|
|
const loading = document.getElementById('loading');
|
|
const results = document.getElementById('results');
|
|
const errorContainer = document.getElementById('errorContainer');
|
|
const ipTableBody = document.getElementById('ipTableBody');
|
|
const availableCount = document.getElementById('availableCount');
|
|
const usedCount = document.getElementById('usedCount');
|
|
const segmentDisplay = document.getElementById('segmentDisplay');
|
|
const cancelBtn = document.getElementById('cancelBtn');
|
|
let verificationCancelled = false;
|
|
|
|
// Toggle visibility of ping limit select
|
|
<?php if ($pingEnabled): ?>
|
|
const verifyPingCheckbox = document.getElementById('verifyPing');
|
|
const pingLimitContainer = document.getElementById('pingLimitContainer');
|
|
if (verifyPingCheckbox && pingLimitContainer) {
|
|
verifyPingCheckbox.addEventListener('change', function() {
|
|
pingLimitContainer.style.display = this.checked ? 'block' : 'none';
|
|
});
|
|
}
|
|
<?php endif; ?>
|
|
|
|
searchForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const segment = document.getElementById('segment').value;
|
|
|
|
// Validar segmento
|
|
const segmentNum = parseInt(segment);
|
|
if (isNaN(segmentNum) || segmentNum < 0 || segmentNum > 255) {
|
|
showError('El segmento debe ser un número entre 0 y 255');
|
|
return;
|
|
}
|
|
|
|
// Mostrar loading
|
|
<?php if ($pingEnabled): ?>
|
|
const verifyPingCheckbox = document.getElementById('verifyPing');
|
|
const loadingPingWarning = document.getElementById('loadingPingWarning');
|
|
if (verifyPingCheckbox && verifyPingCheckbox.checked) {
|
|
loadingPingWarning.style.display = 'block';
|
|
}
|
|
<?php endif; ?>
|
|
|
|
loading.classList.add('active');
|
|
results.classList.remove('active');
|
|
errorContainer.innerHTML = '';
|
|
searchBtn.disabled = true;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'search');
|
|
formData.append('segment', segment);
|
|
|
|
// Agregar verificación por ping si está habilitada y marcada
|
|
<?php if ($pingEnabled): ?>
|
|
const verifyPingCheckbox = document.getElementById('verifyPing');
|
|
const pingLimitSelect = document.getElementById('pingLimit');
|
|
const shouldVerifyPing = verifyPingCheckbox && verifyPingCheckbox.checked;
|
|
const pingLimit = pingLimitSelect ? parseInt(pingLimitSelect.value) : 0;
|
|
|
|
// NOTA: Ya no enviamos verify_ping=true al backend para la búsqueda inicial
|
|
// La verificación se hará progresivamente después de recibir la lista
|
|
// Solo lo enviamos como 'false' para que el backend sepa que no debe verificar
|
|
formData.append('verify_ping', 'false');
|
|
<?php endif; ?>
|
|
|
|
console.log('Enviando petición AJAX...');
|
|
console.log('Segmento:', segment);
|
|
|
|
const response = await fetch('', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
console.log('Respuesta recibida:', response.status, response.statusText);
|
|
|
|
// Verificar si la respuesta es JSON
|
|
const contentType = response.headers.get('content-type');
|
|
console.log('Content-Type:', contentType);
|
|
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
const textResponse = await response.text();
|
|
console.error('Respuesta no es JSON:', textResponse.substring(0, 500));
|
|
throw new Error('El servidor no devolvió una respuesta JSON válida');
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Datos recibidos:', data);
|
|
|
|
loading.classList.remove('active');
|
|
<?php if ($pingEnabled): ?>
|
|
loadingPingWarning.style.display = 'none';
|
|
<?php endif; ?>
|
|
searchBtn.disabled = false;
|
|
|
|
if (data.success) {
|
|
const clientIps = displayResults(data);
|
|
|
|
// Iniciar verificación progresiva si corresponde
|
|
<?php if ($pingEnabled): ?>
|
|
if (shouldVerifyPing) {
|
|
runProgressiveVerification(clientIps, pingLimit);
|
|
} else {
|
|
// Si no se verifica con ping, mostrar todas las IPs de cliente de inmediato
|
|
clientIps.forEach(ip => renderRow(ip, 'Sólo validada con UISP', 'pending'));
|
|
}
|
|
<?php else: ?>
|
|
// Si ping no está habilitado en config, mostrar todas
|
|
clientIps.forEach(ip => renderRow(ip, 'Sólo validada con UISP', 'pending'));
|
|
<?php endif; ?>
|
|
} else {
|
|
showError(data.message || 'Error al buscar IPs disponibles');
|
|
}
|
|
} catch (error) {
|
|
loading.classList.remove('active');
|
|
<?php if ($pingEnabled): ?>
|
|
loadingPingWarning.style.display = 'none'; // Ocultar advertencia
|
|
<?php endif; ?>
|
|
searchBtn.disabled = false;
|
|
console.error('Error completo:', error);
|
|
console.error('Tipo de error:', error.name);
|
|
console.error('Mensaje:', error.message);
|
|
console.error('Stack:', error.stack);
|
|
showError('Error de conexión: ' + error.message + '. Revise la consola del navegador para más detalles.');
|
|
}
|
|
});
|
|
|
|
function displayResults(data) {
|
|
// Actualizar estadísticas
|
|
availableCount.textContent = data.data.length;
|
|
usedCount.textContent = data.used ? data.used.length : 0;
|
|
segmentDisplay.textContent = data.segment || '-';
|
|
|
|
// Limpiar tabla
|
|
ipTableBody.innerHTML = '';
|
|
|
|
// Mostrar mensaje si no hay IPs disponibles
|
|
if (data.data.length === 0) {
|
|
showError('No hay IPs disponibles en este segmento', 'warning');
|
|
return [];
|
|
}
|
|
|
|
const clientIps = [];
|
|
|
|
// Separar y renderizar solo IPs de administración
|
|
data.data.forEach((ip, index) => {
|
|
const ipTypeLabel = getIpType(ip);
|
|
|
|
if (ipTypeLabel === 'Administración') {
|
|
renderRow(ip, 'Sólo validada con UISP', 'pending');
|
|
} else {
|
|
clientIps.push(ip);
|
|
}
|
|
});
|
|
|
|
// Mostrar resultados container
|
|
results.classList.add('active');
|
|
|
|
// Mostrar mensaje de éxito
|
|
showError(data.message, 'success');
|
|
|
|
return clientIps;
|
|
}
|
|
|
|
function renderRow(ip, statusText, statusClass) {
|
|
const ipTypeLabel = getIpType(ip);
|
|
const hideAdminCheckbox = document.getElementById('hideAdmin');
|
|
const isHidden = hideAdminCheckbox && hideAdminCheckbox.checked && ipTypeLabel === 'Administración';
|
|
|
|
const row = document.createElement('tr');
|
|
row.id = `row-${ip.replace(/\./g, '-')}`;
|
|
if (isHidden) row.classList.add('hidden-row');
|
|
if (ipTypeLabel === 'Administración') row.classList.add('admin-row');
|
|
|
|
// Calcular índice visual (opcional, por ahora usamos conteo de filas)
|
|
const index = ipTableBody.children.length + 1;
|
|
|
|
row.innerHTML = `
|
|
<td>${index}</td>
|
|
<td><span class="ip-address">${ip}</span></td>
|
|
<td>
|
|
<span class="ip-type-badge ip-type-${ipTypeLabel === 'Administración' ? 'admin' : 'client'}">
|
|
${ipTypeLabel}
|
|
</span>
|
|
</td>
|
|
<td id="status-${ip.replace(/\./g, '-')}">
|
|
<span class="status-badge status-${statusClass}">${statusText}</span>
|
|
</td>
|
|
<td>
|
|
<button class="btn-copy" onclick="copyToClipboard('${ip}', this)">
|
|
📋 Copiar
|
|
</button>
|
|
</td>
|
|
`;
|
|
ipTableBody.appendChild(row);
|
|
}
|
|
|
|
// Listener para el filtro de Admin IPs
|
|
const hideAdminCheckbox = document.getElementById('hideAdmin');
|
|
if (hideAdminCheckbox) {
|
|
hideAdminCheckbox.addEventListener('change', function() {
|
|
const adminRows = document.querySelectorAll('.admin-row');
|
|
adminRows.forEach(row => {
|
|
if (this.checked) {
|
|
row.classList.add('hidden-row');
|
|
} else {
|
|
row.classList.remove('hidden-row');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Función para clasificar el tipo de IP
|
|
function getIpType(ip) {
|
|
const parts = ip.split('.');
|
|
const lastOctet = parseInt(parts[3]);
|
|
|
|
if ((lastOctet >= 1 && lastOctet <= 30) || lastOctet === 254) {
|
|
return 'Administración';
|
|
}
|
|
return 'Válida para cliente';
|
|
}
|
|
|
|
async function runProgressiveVerification(clientIps, limit) {
|
|
let ipsToVerify = [];
|
|
|
|
// 1. Filtrar IPs a verificar según el límite
|
|
if (limit > 0) {
|
|
// Si hay límite, solo tomamos las primeras N
|
|
ipsToVerify = clientIps.slice(0, limit);
|
|
} else {
|
|
// Si límite es 0 (Todas), verificamos todas
|
|
ipsToVerify = [...clientIps];
|
|
}
|
|
|
|
console.log(`Iniciando verificación progresiva de ${ipsToVerify.length} IPs`);
|
|
|
|
// Mostrar botón de cancelar
|
|
verificationCancelled = false;
|
|
if (cancelBtn) {
|
|
cancelBtn.style.display = 'inline-flex';
|
|
}
|
|
|
|
// 2. Procesar una por una (secuencialmente)
|
|
for (const ip of ipsToVerify) {
|
|
// Verificar si el usuario canceló
|
|
if (verificationCancelled) {
|
|
console.log('Verificación cancelada por el usuario');
|
|
showError('Verificación cancelada. Mostrando resultados parciales.', 'warning');
|
|
break;
|
|
}
|
|
|
|
// A. Renderizar fila con estado "Verificando"
|
|
renderRow(ip, '⏳ Verificando...', 'verifying');
|
|
|
|
// Scroll al final de la tabla para seguir el progreso
|
|
const tableContainer = document.querySelector('.table-container');
|
|
tableContainer.scrollTop = tableContainer.scrollHeight;
|
|
|
|
// B. Verificar (lote de 1)
|
|
await verifyBatch([ip]);
|
|
}
|
|
|
|
// Ocultar botón de cancelar al finalizar
|
|
if (cancelBtn) {
|
|
cancelBtn.style.display = 'none';
|
|
}
|
|
|
|
// NOTA: Las IPs que exceden el límite NO se renderizan, cumpliendo el requerimiento.
|
|
}
|
|
|
|
// Event listener para botón de cancelar
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', () => {
|
|
verificationCancelled = true;
|
|
cancelBtn.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
async function verifyBatch(ips) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'verify_batch');
|
|
formData.append('ips', JSON.stringify(ips));
|
|
|
|
// Timeout de 30 segundos por lote
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
|
|
const response = await fetch('', {
|
|
method: 'POST',
|
|
body: formData,
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (e) {
|
|
console.error('Respuesta no válida del servidor:', text);
|
|
throw new Error('Respuesta inválida del servidor');
|
|
}
|
|
|
|
if (data.success) {
|
|
// Actualizar UI
|
|
// Responding = Conflicto (Rojo)
|
|
if (data.responding) {
|
|
data.responding.forEach(ip => {
|
|
updateStatus(ip, 'conflict', '⚠️ En uso (Ping)');
|
|
});
|
|
}
|
|
|
|
// Not Responding = Disponible (Verde)
|
|
if (data.not_responding) {
|
|
data.not_responding.forEach(ip => {
|
|
updateStatus(ip, 'available', '✅ Disponible');
|
|
});
|
|
}
|
|
} else {
|
|
throw new Error(data.message || 'Error en verificación');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error verificando lote:', error);
|
|
// Marcar como error visualmente para que no se quede cargando
|
|
ips.forEach(ip => {
|
|
updateStatus(ip, 'unknown', '❓ Error verificación');
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateStatus(ip, type, text) {
|
|
const cellId = `status-${ip.replace(/\./g, '-')}`;
|
|
const cell = document.getElementById(cellId);
|
|
if (cell) {
|
|
cell.innerHTML = `<span class="status-badge status-${type}">${text}</span>`;
|
|
}
|
|
}
|
|
|
|
function showError(message, type = 'error') {
|
|
const alertClass = type === 'success' ? 'alert-success' : 'alert-error';
|
|
const icon = type === 'success' ? '✓' : '⚠';
|
|
|
|
errorContainer.innerHTML = `
|
|
<div class="alert ${alertClass}">
|
|
<span style="font-size: 1.5rem;">${icon}</span>
|
|
<span>${message}</span>
|
|
</div>
|
|
`;
|
|
|
|
// Auto-ocultar mensajes de éxito después de 5 segundos
|
|
if (type === 'success') {
|
|
setTimeout(() => {
|
|
errorContainer.innerHTML = '';
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text, button) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
const originalText = button.innerHTML;
|
|
button.innerHTML = '✓ Copiado';
|
|
button.classList.add('copied');
|
|
|
|
setTimeout(() => {
|
|
button.innerHTML = originalText;
|
|
button.classList.remove('copied');
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Error al copiar:', error);
|
|
alert('Error al copiar al portapapeles');
|
|
}
|
|
}
|
|
|
|
// Auto-focus en el campo de segmento
|
|
document.getElementById('segment').focus();
|
|
</script>
|
|
</body>
|
|
</html>
|