Uses Chart.js to render line charts from servidor_metricas_history. Chart loads when opening the server detail modal and downsamples to max 300 points for performance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
926 lines
49 KiB
HTML
926 lines
49 KiB
HTML
<!-- Servidor Dashboard -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
|
<div x-data="servidorDashboard()" x-init="init()">
|
|
<!-- Header -->
|
|
<div class="mb-8 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-slate-800">Dashboard de Servidores</h1>
|
|
<p class="text-slate-500 text-sm mt-2">Monitorea todos tus servidores y sus conexiones de base de datos</p>
|
|
</div>
|
|
<button @click="cargarServidores()" class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
Actualizar
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div x-show="cargando" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<template x-for="i in [1,2,3]">
|
|
<div class="bg-white rounded-lg border border-slate-200 h-48 animate-pulse">
|
|
<div class="bg-slate-200 h-16 rounded-t-lg"></div>
|
|
<div class="p-4 space-y-3">
|
|
<div class="h-4 bg-slate-200 rounded w-3/4"></div>
|
|
<div class="h-4 bg-slate-200 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Servidor List -->
|
|
<div x-show="!cargando" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<template x-for="servidor in servidores" :key="servidor.ID">
|
|
<div class="bg-white rounded-xl border border-slate-200 shadow-sm hover:shadow-lg transition-all overflow-hidden group">
|
|
<!-- Card Header -->
|
|
<div class="bg-gradient-to-br from-slate-700 to-slate-900 p-5">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h3 class="text-white font-bold text-base" x-text="servidor.nombre"></h3>
|
|
<p class="text-slate-300 text-xs mt-1 font-mono" x-text="servidor.ip_servidor"></p>
|
|
</div>
|
|
<div class="flex flex-col items-end gap-1.5">
|
|
<span class="text-xs px-2 py-1 rounded-full font-semibold"
|
|
:class="servidor.tipo_servidor?.nombre ? 'bg-blue-500 text-white' : 'bg-slate-600 text-slate-300'"
|
|
x-text="servidor.tipo_servidor?.nombre || 'Servidor'"></span>
|
|
<!-- Badge estado agente -->
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-bold"
|
|
:class="agentOnline(servidor) ? 'bg-green-400 text-green-900' : (servidor.agent_token ? 'bg-red-400 text-red-900' : 'bg-slate-500 text-slate-200')">
|
|
<span x-show="agentOnline(servidor)">● En línea</span>
|
|
<span x-show="!agentOnline(servidor) && servidor.agent_token">● Fuera</span>
|
|
<span x-show="!servidor.agent_token">○ Sin agente</span>
|
|
</span> <!-- Badge estado Hostinger: solo mostrar cuando NO es running (estados críticos como stopped/error) -->
|
|
<span x-show="servidor.hostinger_vps_id && servidor.hostinger_state && servidor.hostinger_state !== 'running'" class="text-xs px-2 py-0.5 rounded-full font-bold bg-amber-200 text-amber-900">
|
|
<span x-text="servidor.hostinger_state" class="capitalize"></span>
|
|
</span> </div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Body -->
|
|
<div class="p-4 space-y-3">
|
|
|
|
<!-- Métricas live (si hay agente) -->
|
|
<template x-if="servidor.metricas_json">
|
|
<div x-data="{ m: parseMetricas(servidor.metricas_json) }" class="space-y-2">
|
|
<!-- RAM -->
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500 font-medium">🧠 RAM</span>
|
|
<span class="font-mono font-bold text-slate-700"
|
|
x-text="m.ram?.usado_gb + ' / ' + m.ram?.total_gb + ' GB (' + m.ram?.porcentaje + '%)'"></span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1.5">
|
|
<div class="h-1.5 rounded-full transition-all"
|
|
:class="m.ram?.porcentaje > 85 ? 'bg-red-500' : m.ram?.porcentaje > 65 ? 'bg-amber-400' : 'bg-green-500'"
|
|
:style="'width:' + Math.min(m.ram?.porcentaje, 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
<!-- CPU -->
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500 font-medium">⚙️ CPU</span>
|
|
<span class="font-mono font-bold text-slate-700"
|
|
x-text="m.cpu?.porcentaje + '% · ' + m.cpu?.nucleos + ' núcleos'"></span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1.5">
|
|
<div class="h-1.5 rounded-full transition-all"
|
|
:class="m.cpu?.porcentaje > 85 ? 'bg-red-500' : m.cpu?.porcentaje > 65 ? 'bg-amber-400' : 'bg-blue-500'"
|
|
:style="'width:' + Math.min(m.cpu?.porcentaje, 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Discos -->
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500 font-medium">💾 Discos</span>
|
|
<span class="font-mono font-bold text-slate-700" x-text="(m.discos || []).length"></span>
|
|
</div>
|
|
<template x-for="d in (m.discos || []).slice(0, 3)" :key="d.ruta">
|
|
<div class="mb-1.5">
|
|
<div class="flex justify-between text-xs mb-0.5">
|
|
<span class="text-slate-400 font-mono text-[10px]" x-text="d.ruta"></span>
|
|
<span class="font-mono font-bold text-slate-700 text-[10px]"
|
|
x-text="d.usado_gb + ' / ' + d.total_gb + ' GB (' + d.porcentaje + '%)'"></span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1">
|
|
<div class="h-1 rounded-full transition-all"
|
|
:class="d.porcentaje > 85 ? 'bg-red-500' : d.porcentaje > 65 ? 'bg-amber-400' : 'bg-indigo-500'"
|
|
:style="'width:' + Math.min(d.porcentaje, 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="(m.discos || []).length > 3">
|
|
<p class="text-[10px] text-slate-400 mt-0.5" x-text="'+' + (m.discos.length - 3) + ' más'"></p>
|
|
</template>
|
|
</div>
|
|
<!-- Swap -->
|
|
<template x-if="m.swap?.total_gb > 0">
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500 font-medium">🔄 Swap</span>
|
|
<span class="font-mono font-bold text-slate-700"
|
|
x-text="m.swap.usado_gb + ' / ' + m.swap.total_gb + ' GB (' + m.swap.porcentaje + '%)'"></span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1.5">
|
|
<div class="h-1.5 rounded-full transition-all"
|
|
:class="m.swap.porcentaje > 85 ? 'bg-red-500' : m.swap.porcentaje > 50 ? 'bg-amber-400' : 'bg-purple-400'"
|
|
:style="'width:' + Math.min(m.swap.porcentaje, 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Red -->
|
|
<template x-if="(m.red || []).length > 0">
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500 font-medium">🌐 Red</span>
|
|
<span class="text-[10px] text-slate-400" x-text="(m.red || []).length + ' interfaz(es)'"></span>
|
|
</div>
|
|
<template x-for="r in (m.red || []).slice(0,2)" :key="r.interface">
|
|
<div class="flex justify-between text-[10px] text-slate-500 font-mono mb-0.5">
|
|
<span x-text="r.interface"></span>
|
|
<span>
|
|
<span class="text-green-600">↓ <span x-text="fmtMB(r.bytes_rec_mb)"></span></span>
|
|
<span class="text-blue-600 ml-1">↑ <span x-text="fmtMB(r.bytes_env_mb)"></span></span>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- TCP + Top Procs -->
|
|
<div class="flex items-center justify-between text-xs text-slate-500 pt-1 border-t border-slate-100">
|
|
<span>🔌 TCP: <strong x-text="m.tcp_conns ?? '—'"></strong></span>
|
|
<span>📊 Load: <strong x-text="m.load_avg?.load1 ?? '—'"></strong></span>
|
|
</div>
|
|
|
|
<!-- Top Procesos -->
|
|
<template x-if="(m.top_procs || []).length > 0">
|
|
<div x-data="{ showProcs: false }">
|
|
<button @click="showProcs=!showProcs" class="w-full text-left text-[10px] text-slate-400 hover:text-slate-600 flex items-center gap-1">
|
|
<svg class="w-3 h-3 transition-transform" :class="showProcs ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
Top procesos por RAM (<span x-text="(m.top_procs||[]).length"></span>)
|
|
</button>
|
|
<template x-if="showProcs">
|
|
<div class="mt-1.5 bg-slate-50 rounded-lg overflow-hidden">
|
|
<table class="w-full text-[10px]">
|
|
<thead><tr class="text-slate-400 border-b border-slate-200">
|
|
<th class="px-2 py-1 text-left">Proceso</th>
|
|
<th class="px-2 py-1 text-right">CPU%</th>
|
|
<th class="px-2 py-1 text-right">RAM</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<template x-for="p in (m.top_procs||[]).slice(0,10)" :key="p.pid">
|
|
<tr class="border-b border-slate-100 last:border-0">
|
|
<td class="px-2 py-1 font-mono truncate max-w-[90px]" x-text="p.nombre"></td>
|
|
<td class="px-2 py-1 text-right font-bold"
|
|
:class="p.cpu > 50 ? 'text-red-600' : p.cpu > 20 ? 'text-amber-600' : 'text-slate-600'"
|
|
x-text="p.cpu + '%'"></td>
|
|
<td class="px-2 py-1 text-right text-slate-500 font-mono" x-text="fmtMB(p.ram_mb)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Uptime + OS -->
|
|
<div class="flex justify-between text-xs text-slate-400 pt-1 border-t border-slate-100">
|
|
<span>⏱ <span x-text="m.uptime || '—'"></span></span>
|
|
<span x-text="m.os || servidor.so || '—'"></span>
|
|
</div>
|
|
<!-- Último reporte -->
|
|
<div class="text-xs text-slate-400 text-right">
|
|
Actualizado <span x-text="tiempoDesde(servidor.agent_last_seen)"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Sin métricas (agente no instalado) -->
|
|
<template x-if="!servidor.metricas_json">
|
|
<div class="grid grid-cols-3 gap-2 text-xs">
|
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
<p class="text-slate-400 font-medium">RAM</p>
|
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.ram || '—'"></p>
|
|
</div>
|
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
<p class="text-slate-400 font-medium">CPU</p>
|
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.nucleos || '—'"></p>
|
|
</div>
|
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
<p class="text-slate-400 font-medium">Disco</p>
|
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.disco || '—'"></p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Provider, Vencimiento & Conexiones -->
|
|
<div class="space-y-1.5 pt-1">
|
|
<!-- Vencimiento -->
|
|
<template x-if="servidor.vencimiento">
|
|
<div x-data="{ info: vencimientoInfo(servidor.vencimiento) }">
|
|
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full"
|
|
:class="info.clase"
|
|
x-text="'📅 ' + info.texto"></span>
|
|
</div>
|
|
</template>
|
|
<div class="flex items-center justify-between text-xs text-slate-500">
|
|
<span x-text="servidor.prov_servidor?.nombre || 'Sin proveedor'"></span>
|
|
<span>
|
|
<span class="font-bold text-blue-600" x-text="servidor.conx_count ?? servidor._conexiones?.length ?? 0"></span>
|
|
BD
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex gap-2 mt-2">
|
|
<button @click="abrirModal(servidor)"
|
|
class="flex-1 px-3 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-1.5 text-sm">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
</svg>
|
|
Ver
|
|
</button>
|
|
<button @click="abrirAgentModal(servidor)" title="Configurar agente de monitoreo"
|
|
class="px-3 py-2 rounded-lg font-medium transition-colors text-sm border flex items-center gap-1"
|
|
:class="agentOnline(servidor) ? 'border-green-300 text-green-700 hover:bg-green-50' : 'border-slate-300 text-slate-600 hover:bg-slate-50'">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18"/>
|
|
</svg>
|
|
Agente
|
|
</button>
|
|
<button x-show="servidor.hostinger_vps_id" @click="syncHostinger(servidor)"
|
|
:disabled="syncCargando[servidor.ID]"
|
|
title="Sincronizar specs desde Hostinger"
|
|
class="px-3 py-2 rounded-lg font-medium transition-colors text-sm border border-orange-300 text-orange-600 hover:bg-orange-50 flex items-center gap-1 disabled:opacity-40">
|
|
<svg :class="syncCargando[servidor.ID] ? 'animate-spin' : ''" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Empty State -->
|
|
<div x-show="!cargando && servidores.length === 0" class="col-span-full text-center py-20 text-slate-400">
|
|
<svg class="w-16 h-16 mx-auto mb-4 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/>
|
|
</svg>
|
|
<p class="text-lg font-medium">No hay servidores registrados</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== Modal Detalle Servidor ===== -->
|
|
<div x-show="servidorSeleccionado" x-cloak
|
|
class="fixed inset-0 bg-black/60 z-50 flex items-start justify-center p-4 overflow-y-auto"
|
|
@click.self="cerrarModal()">
|
|
<div class="bg-white rounded-xl w-full max-w-5xl my-8 shadow-2xl">
|
|
|
|
<!-- Modal Header -->
|
|
<div class="bg-gradient-to-br from-slate-700 to-slate-900 p-6 rounded-t-xl flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-2xl font-bold text-white" x-text="servidorSeleccionado?.nombre"></h2>
|
|
<p class="text-slate-300 text-sm mt-1 font-mono" x-text="servidorSeleccionado?.ip_servidor"></p>
|
|
</div>
|
|
<button @click="cerrarModal()" class="text-slate-300 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-6 space-y-8">
|
|
|
|
<!-- === Info Grid === -->
|
|
<div>
|
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">Información del Servidor</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">🌐 IP Servidor</p>
|
|
<p class="font-bold text-slate-900 font-mono text-sm break-all" x-text="servidorSeleccionado?.ip_servidor || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">🏢 Proveedor</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.prov_servidor?.nombre || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">🖥️ Tipo</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.tipo_servidor?.nombre || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">💻 SO</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.so || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">🧠 RAM</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.ram || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">⚙️ Núcleos CPU</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.nucleos || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">💾 Disco</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.disco || '—'"></p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">📅 Vencimiento</p>
|
|
<p class="font-bold text-slate-900 text-sm" x-text="servidorSeleccionado?.vencimiento || '—'"></p>
|
|
</div>
|
|
|
|
<div x-show="servidorSeleccionado?.hostinger_vps_id" class="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
|
<p class="text-xs text-orange-500 font-medium mb-1">🔗 Hostinger VPS</p>
|
|
<p class="font-mono font-bold text-slate-900 text-sm">
|
|
ID: <span x-text="servidorSeleccionado?.hostinger_vps_id"></span>
|
|
<span x-show="servidorSeleccionado?.hostinger_subscription_id" class="block text-xs text-slate-500 font-normal mt-0.5">
|
|
Sub: <span x-text="servidorSeleccionado?.hostinger_subscription_id" class="font-mono"></span>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div x-show="servidorSeleccionado?.ultimo_ping" class="bg-slate-50 border border-slate-200 rounded-xl p-4 md:col-span-2">
|
|
<p class="text-xs text-slate-400 font-medium mb-1">📡 Último Ping</p>
|
|
<p class="font-bold text-slate-900 text-sm font-mono" x-text="servidorSeleccionado?.ultimo_ping"></p>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- === Historial de Métricas === -->
|
|
<template x-if="servidorSeleccionado?.agent_token">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider">Historial de Métricas</h3>
|
|
<div class="flex gap-1">
|
|
<template x-for="opt in [{h:1,l:'1h'},{h:6,l:'6h'},{h:24,l:'24h'},{h:168,l:'7d'}]" :key="opt.h">
|
|
<button @click="cargarHistorial(opt.h)"
|
|
:class="historialHoras === opt.h ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
|
class="px-3 py-1 rounded-lg text-xs font-bold transition-colors"
|
|
x-text="opt.l"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<template x-if="historialCargando">
|
|
<div class="flex items-center justify-center h-40 text-slate-400 text-sm gap-2">
|
|
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
Cargando historial...
|
|
</div>
|
|
</template>
|
|
<template x-if="!historialCargando && historialVacio">
|
|
<div class="flex items-center justify-center h-40 text-slate-400 text-sm">Sin datos en este período</div>
|
|
</template>
|
|
<div x-show="!historialCargando && !historialVacio">
|
|
<canvas id="metricasChart" height="120"></canvas>
|
|
</div>
|
|
<!-- Leyenda -->
|
|
<div x-show="!historialCargando && !historialVacio" class="flex gap-4 mt-3 justify-center text-xs text-slate-500">
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-blue-500 inline-block"></span>CPU %</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-green-500 inline-block"></span>RAM %</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-amber-400 inline-block"></span>Disco %</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- === Conexiones BD === -->
|
|
<div>
|
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">
|
|
Conexiones de Base de Datos
|
|
<span class="ml-2 bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-bold normal-case"
|
|
x-text="(servidorSeleccionado?._conexiones?.length || 0) + ' conexiones'"></span>
|
|
</h3>
|
|
|
|
<!-- Cargando conexiones -->
|
|
<div x-show="cargandoConexiones" class="text-center py-8 text-slate-400">
|
|
<div class="inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-2"></div>
|
|
<p class="text-sm">Cargando conexiones...</p>
|
|
</div>
|
|
|
|
<!-- Grid de conexiones -->
|
|
<div x-show="!cargandoConexiones && servidorSeleccionado?._conexiones?.length > 0"
|
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<template x-for="cx in (servidorSeleccionado?._conexiones || [])" :key="(cx.ID || cx.id)">
|
|
<div class="bg-white border-2 border-slate-200 rounded-xl overflow-hidden hover:shadow-md transition-all">
|
|
|
|
<!-- DB header with type color -->
|
|
<div class="px-4 py-3 flex items-center justify-between" :class="getTipoDbBg(cx.tipo_db?.nombre)">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-white font-bold text-sm px-2 py-0.5 rounded bg-black/20"
|
|
x-text="cx.tipo_db?.nombre || 'DB'"></span>
|
|
<span class="text-white/80 text-xs font-mono" x-text="(cx.nombre ? cx.nombre + ' · ' : '') + '#' + (cx.ID || cx.id)"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DB details -->
|
|
<div class="p-4 space-y-2 text-xs">
|
|
<div class="flex justify-between">
|
|
<span class="text-slate-500 font-medium">Host</span>
|
|
<span class="text-slate-900 font-mono" x-text="cx.servidor?.ip_servidor || servidorSeleccionado?.ip_servidor || '—'"></span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-slate-500 font-medium">Puerto</span>
|
|
<span class="text-slate-900 font-mono font-bold" x-text="cx.puerto || '—'"></span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-slate-500 font-medium">Usuario</span>
|
|
<span class="text-slate-900 font-mono" x-text="cx.usuario || '—'"></span>
|
|
</div>
|
|
|
|
<!-- Resultado del ping -->
|
|
<div x-show="cx._pingResultado !== null" class="mt-1">
|
|
<div :class="cx._pingResultado?.exitoso ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'"
|
|
class="rounded-lg px-3 py-2 border text-xs flex flex-col gap-1">
|
|
<div class="flex items-center gap-2">
|
|
<span x-text="cx._pingResultado?.exitoso ? '✓' : '✗'" class="font-bold text-base leading-none"></span>
|
|
<span x-show="cx._pingResultado?.exitoso" x-text="'Conectado en ' + cx._pingResultado?.tiempo + ' ms'"></span>
|
|
<span x-show="!cx._pingResultado?.exitoso" class="font-medium">Sin conexión</span>
|
|
</div>
|
|
<span x-show="cx._pingResultado?.host" class="font-mono opacity-70"
|
|
x-text="(cx._pingResultado?.host || '') + ':' + (cx._pingResultado?.puerto || '')"></span>
|
|
<span x-show="!cx._pingResultado?.exitoso && cx._pingResultado?.error"
|
|
x-text="cx._pingResultado?.error" class="opacity-80 break-words"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ping + Query Runner buttons -->
|
|
<div class="pt-3 border-t border-slate-100 flex items-center justify-between">
|
|
<button type="button" @click.stop="hacerPing(cx)"
|
|
:disabled="cx._pingCargando"
|
|
class="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
:class="cx._pingCargando ? 'bg-slate-100 text-slate-400 cursor-wait' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'">
|
|
<svg x-show="!cx._pingCargando" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
|
</svg>
|
|
<svg x-show="cx._pingCargando" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
<span x-text="cx._pingCargando ? 'Probando...' : 'Verificar'"></span>
|
|
</button>
|
|
|
|
<button type="button" @click="abrirQueryRunner(cx)"
|
|
class="flex items-center gap-1 px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-xs font-medium hover:bg-slate-100 transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
Query Runner
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Sin conexiones -->
|
|
<div x-show="!cargandoConexiones && (!servidorSeleccionado?._conexiones || servidorSeleccionado._conexiones.length === 0)"
|
|
class="text-center py-12 bg-slate-50 rounded-xl border border-slate-200">
|
|
<svg class="w-10 h-10 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4"/>
|
|
</svg>
|
|
<p class="text-slate-500 font-medium text-sm">Este servidor no tiene conexiones de base de datos</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== Modal Agente ===== -->
|
|
<div x-show="agentModalServidor" x-cloak
|
|
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
|
@click.self="agentModalServidor = null">
|
|
<div class="bg-white rounded-xl w-full max-w-2xl shadow-2xl overflow-hidden" x-data>
|
|
|
|
<!-- Header -->
|
|
<div class="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-white font-bold text-lg">⚡ Agente de Monitoreo</h2>
|
|
<p class="text-emerald-100 text-sm mt-0.5" x-text="agentModalServidor?.nombre"></p>
|
|
</div>
|
|
<button @click="agentModalServidor = null" class="text-emerald-200 hover:text-white p-1.5 rounded-lg hover:bg-white/10">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-6 space-y-5 max-h-[75vh] overflow-y-auto">
|
|
|
|
<!-- Estado actual -->
|
|
<div class="rounded-xl border p-4"
|
|
:class="agentOnline(agentModalServidor) ? 'bg-green-50 border-green-200' : (agentModalServidor?.agent_token ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200')">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-3 h-3 rounded-full animate-pulse"
|
|
:class="agentOnline(agentModalServidor) ? 'bg-green-500' : (agentModalServidor?.agent_token ? 'bg-amber-400' : 'bg-slate-400')"></div>
|
|
<div>
|
|
<p class="font-bold text-sm"
|
|
:class="agentOnline(agentModalServidor) ? 'text-green-800' : (agentModalServidor?.agent_token ? 'text-amber-800' : 'text-slate-700')">
|
|
<span x-show="agentOnline(agentModalServidor)">✅ Agente en línea</span>
|
|
<span x-show="!agentOnline(agentModalServidor) && agentModalServidor?.agent_token">⚠️ Agente instalado pero sin reportes recientes</span>
|
|
<span x-show="!agentModalServidor?.agent_token">🔧 Agente no configurado</span>
|
|
</p>
|
|
<p class="text-xs mt-0.5"
|
|
:class="agentOnline(agentModalServidor) ? 'text-green-600' : 'text-slate-500'"
|
|
x-text="agentModalServidor?.agent_last_seen ? 'Último reporte: ' + tiempoDesde(agentModalServidor.agent_last_seen) : 'Nunca ha reportado'">
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Métricas actuales si está online -->
|
|
<template x-if="agentModalServidor?.metricas_json">
|
|
<div x-data="{ m: parseMetricas(agentModalServidor?.metricas_json) }" class="mt-3 grid grid-cols-3 gap-3 text-xs">
|
|
<div class="bg-white rounded-lg p-2 text-center border">
|
|
<p class="text-slate-400">RAM</p>
|
|
<p class="font-bold text-slate-800" x-text="m.ram?.porcentaje + '%'"></p>
|
|
<p class="text-slate-500" x-text="m.ram?.usado_gb + '/' + m.ram?.total_gb + 'GB'"></p>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-2 text-center border">
|
|
<p class="text-slate-400">CPU</p>
|
|
<p class="font-bold text-slate-800" x-text="m.cpu?.porcentaje + '%'"></p>
|
|
<p class="text-slate-500" x-text="m.cpu?.nucleos + ' núcleos'"></p>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-2 text-center border">
|
|
<p class="text-slate-400">Discos</p>
|
|
<p class="font-bold text-slate-800" x-text="(m.discos || []).length"></p>
|
|
<p class="text-slate-500 text-[10px] leading-tight" x-text="(m.discos || []).map(d => d.ruta + ' ' + d.porcentaje + '%').join(' | ')"></p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Token section -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="font-bold text-slate-700 text-sm">🔑 Token del servidor</h3>
|
|
<button @click="generarToken()" :disabled="agentTokenCargando"
|
|
class="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
|
<span x-show="!agentTokenCargando" x-text="agentModalServidor?.agent_token ? '🔄 Regenerar' : '✨ Generar Token'"></span>
|
|
<span x-show="agentTokenCargando">Generando...</span>
|
|
</button>
|
|
</div>
|
|
<template x-if="agentModalServidor?.agent_token">
|
|
<div class="flex items-center gap-2">
|
|
<code class="flex-1 bg-slate-100 rounded-lg px-3 py-2 text-xs font-mono text-slate-700 break-all"
|
|
x-text="agentModalServidor.agent_token"></code>
|
|
<button @click="copiar(agentModalServidor.agent_token)" title="Copiar token"
|
|
class="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors shrink-0">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="!agentModalServidor?.agent_token">
|
|
<p class="text-xs text-slate-400 bg-slate-50 rounded-lg px-3 py-2">Genera un token para poder instalar el agente en este servidor.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Comando de instalación -->
|
|
<template x-if="agentModalServidor?.agent_token">
|
|
<div>
|
|
<h3 class="font-bold text-slate-700 text-sm mb-2">📦 Instalación en 1 comando (Linux)</h3>
|
|
<div class="bg-slate-900 rounded-xl p-4 relative group">
|
|
<pre class="text-green-400 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"
|
|
x-text="`curl -sL https://admin.u-site.app/agent/install.sh | bash -s -- \\\n --token=${agentModalServidor.agent_token} \\\n --url=https://admin.u-site.app \\\n --interval=30`"></pre>
|
|
<button @click="copiar(`curl -sL https://admin.u-site.app/agent/install.sh | bash -s -- --token=${agentModalServidor.agent_token} --url=https://admin.u-site.app --interval=30`)"
|
|
class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg p-1.5">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-2">⚡ El script descarga el binario, crea el archivo de configuración e instala el servicio systemd automáticamente.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Pasos manuales -->
|
|
<template x-if="agentModalServidor?.agent_token">
|
|
<details class="group">
|
|
<summary class="cursor-pointer text-sm font-bold text-slate-600 hover:text-slate-800 select-none flex items-center gap-2">
|
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
Instalación manual / configuración avanzada
|
|
</summary>
|
|
<div class="mt-3 space-y-3 pl-6 text-xs text-slate-600">
|
|
<p><span class="font-bold">1.</span> Descarga el binario para tu arquitectura desde el panel</p>
|
|
<p><span class="font-bold">2.</span> Crea el archivo <code class="bg-slate-100 px-1 rounded">agent.yml</code>:</p>
|
|
<pre class="bg-slate-900 text-green-400 rounded-lg p-3 text-xs font-mono"
|
|
x-text="`api_url: https://admin.u-site.app\ntoken: ${agentModalServidor.agent_token}\ninterval: 30\ndebug: false`"></pre>
|
|
<p><span class="font-bold">3.</span> Ejecuta: <code class="bg-slate-100 px-1 rounded">./usite-agent --config=agent.yml</code></p>
|
|
<p><span class="font-bold">Ver logs:</span> <code class="bg-slate-100 px-1 rounded">journalctl -u usite-agent -f</code></p>
|
|
</div>
|
|
</details>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function servidorDashboard() {
|
|
return {
|
|
servidores: [],
|
|
servidorSeleccionado: null,
|
|
agentModalServidor: null,
|
|
agentTokenCargando: false,
|
|
cargando: true,
|
|
cargandoConexiones: false,
|
|
syncCargando: {},
|
|
pingCargando: {},
|
|
pingResultado: {},
|
|
historialHoras: 24,
|
|
historialCargando: false,
|
|
historialVacio: false,
|
|
_chart: null,
|
|
|
|
async init() {
|
|
await this.cargarServidores();
|
|
},
|
|
|
|
async cargarServidores() {
|
|
this.cargando = true;
|
|
try {
|
|
const r = await fetch('/app/loadservidor?limit=100');
|
|
const data = await r.json();
|
|
this.servidores = data.registros || [];
|
|
} catch (e) {
|
|
console.error('Error cargando servidores:', e);
|
|
this.servidores = [];
|
|
} finally {
|
|
this.cargando = false;
|
|
}
|
|
},
|
|
|
|
async abrirModal(servidor) {
|
|
this.servidorSeleccionado = { ...servidor };
|
|
this.pingCargando = {};
|
|
this.pingResultado = {};
|
|
this.historialHoras = 24;
|
|
this.historialVacio = false;
|
|
this.cargandoConexiones = true;
|
|
if (this._chart) { this._chart.destroy(); this._chart = null; }
|
|
try {
|
|
const r = await fetch(`/app/servidor-dashboard/${servidor.ID}`);
|
|
const data = await r.json();
|
|
const conexiones = (data.conexiones || []).map(c => ({
|
|
...c,
|
|
_pingCargando: false,
|
|
_pingResultado: null,
|
|
}));
|
|
this.servidorSeleccionado._conexiones = conexiones;
|
|
const idx = this.servidores.findIndex(s => s.ID === servidor.ID);
|
|
if (idx >= 0) this.servidores[idx]._conexiones = conexiones;
|
|
} catch (e) {
|
|
console.error('Error cargando conexiones:', e);
|
|
this.servidorSeleccionado._conexiones = [];
|
|
} finally {
|
|
this.cargandoConexiones = false;
|
|
}
|
|
if (servidor.agent_token) {
|
|
await this.$nextTick();
|
|
this.cargarHistorial(24);
|
|
}
|
|
},
|
|
|
|
cerrarModal() {
|
|
if (this._chart) { this._chart.destroy(); this._chart = null; }
|
|
this.servidorSeleccionado = null;
|
|
},
|
|
|
|
async cargarHistorial(horas) {
|
|
if (!this.servidorSeleccionado) return;
|
|
this.historialHoras = horas;
|
|
this.historialCargando = true;
|
|
this.historialVacio = false;
|
|
try {
|
|
const r = await fetch(`/app/servidor/${this.servidorSeleccionado.ID}/metricas-history?horas=${horas}`);
|
|
const data = await r.json();
|
|
if (!data || data.length === 0) { this.historialVacio = true; return; }
|
|
// Downsample si hay muchos puntos (>300 para fluidez)
|
|
const pts = data.length > 300
|
|
? data.filter((_, i) => i % Math.ceil(data.length / 300) === 0)
|
|
: data;
|
|
const labels = pts.map(p => {
|
|
const d = new Date(p.created_at);
|
|
return horas <= 6
|
|
? d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit', second:'2-digit'})
|
|
: horas <= 24
|
|
? d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit'})
|
|
: d.toLocaleDateString('es', {month:'short', day:'numeric'}) + ' ' + d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit'});
|
|
});
|
|
await this.$nextTick();
|
|
const canvas = document.getElementById('metricasChart');
|
|
if (!canvas) return;
|
|
if (this._chart) this._chart.destroy();
|
|
this._chart = new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'CPU %',
|
|
data: pts.map(p => p.cpu_pct),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
|
borderWidth: 1.5,
|
|
pointRadius: 0,
|
|
tension: 0.3,
|
|
fill: true,
|
|
},
|
|
{
|
|
label: 'RAM %',
|
|
data: pts.map(p => p.ram_pct),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: 'rgba(34,197,94,0.08)',
|
|
borderWidth: 1.5,
|
|
pointRadius: 0,
|
|
tension: 0.3,
|
|
fill: true,
|
|
},
|
|
{
|
|
label: 'Disco %',
|
|
data: pts.map(p => p.disco_pct),
|
|
borderColor: '#f59e0b',
|
|
backgroundColor: 'rgba(245,158,11,0.06)',
|
|
borderWidth: 1.5,
|
|
pointRadius: 0,
|
|
tension: 0.1,
|
|
fill: false,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}%`
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { maxTicksLimit: 8, font: { size: 10 }, color: '#94a3b8' },
|
|
grid: { color: '#f1f5f9' },
|
|
},
|
|
y: {
|
|
min: 0, max: 100,
|
|
ticks: { stepSize: 25, font: { size: 10 }, color: '#94a3b8',
|
|
callback: v => v + '%' },
|
|
grid: { color: '#f1f5f9' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error('Error cargando historial:', e);
|
|
this.historialVacio = true;
|
|
} finally {
|
|
this.historialCargando = false;
|
|
}
|
|
},
|
|
|
|
abrirAgentModal(servidor) {
|
|
// Buscar el servidor actualizado en la lista
|
|
const s = this.servidores.find(sv => sv.ID === servidor.ID) || servidor;
|
|
this.agentModalServidor = { ...s };
|
|
},
|
|
|
|
async generarToken() {
|
|
if (!this.agentModalServidor) return;
|
|
this.agentTokenCargando = true;
|
|
try {
|
|
const r = await fetch(`/app/servidor/${this.agentModalServidor.ID}/agent-token`, { method: 'POST' });
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
this.agentModalServidor.agent_token = data.agent_token;
|
|
// Actualizar también en la lista de cards
|
|
const idx = this.servidores.findIndex(s => s.ID === this.agentModalServidor.ID);
|
|
if (idx >= 0) this.servidores[idx].agent_token = data.agent_token;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error generando token:', e);
|
|
} finally {
|
|
this.agentTokenCargando = false;
|
|
}
|
|
},
|
|
|
|
async syncHostinger(servidor) {
|
|
const id = servidor.ID;
|
|
if (!id || this.syncCargando[id]) return;
|
|
this.syncCargando[id] = true;
|
|
try {
|
|
const r = await fetch(`/app/servidor/${id}/sync-hostinger`, { method: 'POST' });
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
await this.cargarServidores();
|
|
} else {
|
|
alert('Error: ' + (data.error || 'No se pudo sincronizar'));
|
|
}
|
|
} catch (e) {
|
|
alert('Error al sincronizar con Hostinger: ' + e.message);
|
|
} finally {
|
|
this.syncCargando[id] = false;
|
|
}
|
|
},
|
|
|
|
agentOnline(servidor) {
|
|
if (!servidor?.agent_last_seen) return false;
|
|
const last = new Date(servidor.agent_last_seen);
|
|
const diffMs = Date.now() - last.getTime();
|
|
return diffMs < 3 * 60 * 1000; // online si reportó en últimos 3 minutos
|
|
},
|
|
|
|
vencimientoInfo(fecha) {
|
|
if (!fecha) return null;
|
|
const hoy = new Date(); hoy.setHours(0,0,0,0);
|
|
const vence = new Date(fecha); vence.setHours(0,0,0,0);
|
|
const dias = Math.round((vence - hoy) / 86400000);
|
|
if (dias < 0) return { texto: 'Vencido', clase: 'bg-red-100 text-red-700' };
|
|
if (dias === 0) return { texto: 'Vence hoy', clase: 'bg-red-100 text-red-700' };
|
|
if (dias <= 7) return { texto: `Vence en ${dias}d`, clase: 'bg-red-100 text-red-700' };
|
|
if (dias <= 30) return { texto: `Vence en ${dias}d`, clase: 'bg-amber-100 text-amber-700' };
|
|
return { texto: fecha, clase: 'bg-slate-100 text-slate-500' };
|
|
},
|
|
|
|
tiempoDesde(isoStr) {
|
|
if (!isoStr) return '—';
|
|
const diff = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000);
|
|
if (diff < 60) return 'hace ' + diff + 's';
|
|
if (diff < 3600) return 'hace ' + Math.floor(diff / 60) + 'm';
|
|
if (diff < 86400) return 'hace ' + Math.floor(diff / 3600) + 'h';
|
|
return 'hace ' + Math.floor(diff / 86400) + 'd';
|
|
},
|
|
|
|
parseMetricas(jsonStr) {
|
|
try { return JSON.parse(jsonStr) || {}; }
|
|
catch { return {}; }
|
|
},
|
|
|
|
fmtMB(mb) {
|
|
if (mb === undefined || mb === null) return '—';
|
|
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
|
|
if (mb >= 1) return mb.toFixed(0) + ' MB';
|
|
return (mb * 1024).toFixed(0) + ' KB';
|
|
},
|
|
|
|
copiar(texto) {
|
|
navigator.clipboard.writeText(texto).catch(() => {});
|
|
},
|
|
|
|
async hacerPing(cx) {
|
|
const id = cx.ID || cx.id;
|
|
if (!id) return;
|
|
// Mutar cx directamente — Alpine v3 rastrea propiedades del objeto
|
|
// dentro de x-for como proxy reactivo, no keys dinámicas de otro objeto
|
|
cx._pingCargando = true;
|
|
cx._pingResultado = null;
|
|
try {
|
|
const r = await fetch(`/app/conx-ping/${id}`);
|
|
if (!r.ok) {
|
|
const txt = await r.text().catch(() => '');
|
|
throw new Error(`HTTP ${r.status}${txt ? ': ' + txt.substring(0, 80) : ''}`);
|
|
}
|
|
cx._pingResultado = await r.json();
|
|
} catch (e) {
|
|
cx._pingResultado = { exitoso: false, error: e.message || 'Sin conexión' };
|
|
} finally {
|
|
cx._pingCargando = false;
|
|
}
|
|
},
|
|
|
|
abrirQueryRunner(cx) {
|
|
window.open(`/app/query-runner?conx_db_id=${cx.ID}`, '_blank');
|
|
},
|
|
|
|
getTipoDbBg(tipo) {
|
|
const m = {
|
|
'MySQL': 'bg-blue-500',
|
|
'MariaDB': 'bg-blue-600',
|
|
'PostgreSQL': 'bg-indigo-600',
|
|
'MongoDB': 'bg-green-600',
|
|
'Redis': 'bg-red-500',
|
|
'SQLite': 'bg-amber-500',
|
|
'SQLServer': 'bg-purple-600',
|
|
'MSSQL': 'bg-purple-600',
|
|
};
|
|
return m[tipo] || 'bg-slate-600';
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
</div>
|