- Tabla con badge de estado (en línea/caído/sin datos), latencia coloreada y último chequeo relativo - Stats rápidos: total, en línea, caídos, latencia promedio - Modal crear/editar con toggle activo, intervalo y timeout configurables - Verificar ahora por fila con spinner y recarga automática a los 4s - Panel lateral con gráfico de barras Chart.js (verde=OK, rojo=error) y tabla de últimos 50 registros - Rangos 1h/6h/24h/7d, autorefresh cada minuto Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
603 lines
29 KiB
HTML
603 lines
29 KiB
HTML
<!-- URL Monitor -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
|
<div x-data="urlMonitorApp()" x-init="init()" @keydown.escape.window="cerrarModal(); cerrarLogs()">
|
|
|
|
<!-- Header -->
|
|
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Monitor de URLs</h1>
|
|
<p class="text-sm text-slate-500 mt-0.5">Verifica disponibilidad de sitios y APIs desde el servidor.</p>
|
|
</div>
|
|
<button @click="abrirNuevo()"
|
|
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg shrink-0"
|
|
style="background-color:#8eb02f"
|
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
|
<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="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Nuevo monitor
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Alertas -->
|
|
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
|
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
|
|
|
|
<!-- Stats -->
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
|
<p class="text-2xl font-bold text-slate-800" x-text="items.length"></p>
|
|
<p class="text-xs text-slate-400 mt-0.5">Total</p>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
|
<p class="text-2xl font-bold text-green-600" x-text="items.filter(m => m.ultimo_ok === true).length"></p>
|
|
<p class="text-xs text-slate-400 mt-0.5">En línea</p>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
|
<p class="text-2xl font-bold text-red-500" x-text="items.filter(m => m.ultimo_ok === false).length"></p>
|
|
<p class="text-xs text-slate-400 mt-0.5">Caídos</p>
|
|
</div>
|
|
<div class="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
|
<p class="text-2xl font-bold text-slate-700" x-text="avgLatencia()"></p>
|
|
<p class="text-xs text-slate-400 mt-0.5">Latencia prom.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabla -->
|
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
|
|
|
<!-- Loading skeleton -->
|
|
<div x-show="cargando" class="divide-y divide-slate-100">
|
|
<template x-for="i in [1,2,3]">
|
|
<div class="px-4 py-4 flex items-center gap-4 animate-pulse">
|
|
<div class="h-3 bg-slate-200 rounded w-1/4"></div>
|
|
<div class="h-3 bg-slate-200 rounded w-1/3"></div>
|
|
<div class="h-5 bg-slate-200 rounded-full w-16 ml-auto"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div x-show="!cargando && items.length === 0" class="py-16 text-center text-slate-400">
|
|
<svg class="w-12 h-12 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="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>
|
|
</svg>
|
|
<p class="text-sm font-medium">No hay monitores configurados</p>
|
|
<p class="text-xs mt-1">Agrega uno con el botón "Nuevo monitor"</p>
|
|
</div>
|
|
|
|
<!-- Table rows -->
|
|
<div x-show="!cargando && items.length > 0">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-slate-50 border-b border-slate-200">
|
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Nombre / URL</th>
|
|
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider hidden sm:table-cell">Intervalo</th>
|
|
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">Estado</th>
|
|
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">Latencia</th>
|
|
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Último chequeo</th>
|
|
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
<template x-for="m in items" :key="m.ID">
|
|
<tr class="hover:bg-slate-50 transition-colors">
|
|
<!-- Nombre / URL -->
|
|
<td class="px-4 py-3">
|
|
<p class="font-semibold text-slate-800" x-text="m.nombre"></p>
|
|
<a :href="m.url" target="_blank" rel="noopener"
|
|
class="text-xs text-blue-500 hover:underline font-mono truncate block max-w-xs"
|
|
x-text="m.url"></a>
|
|
</td>
|
|
<!-- Intervalo -->
|
|
<td class="px-4 py-3 text-center hidden sm:table-cell">
|
|
<span class="text-xs text-slate-500" x-text="'cada ' + m.intervalo_min + ' min'"></span>
|
|
</td>
|
|
<!-- Estado -->
|
|
<td class="px-4 py-3 text-center">
|
|
<template x-if="m.ultimo_ok === null || m.ultimo_ok === undefined">
|
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-slate-400 inline-block"></span>
|
|
Sin datos
|
|
</span>
|
|
</template>
|
|
<template x-if="m.ultimo_ok === true">
|
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block animate-pulse"></span>
|
|
En línea
|
|
</span>
|
|
</template>
|
|
<template x-if="m.ultimo_ok === false">
|
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-red-500 inline-block"></span>
|
|
Caído
|
|
</span>
|
|
</template>
|
|
<template x-if="!m.activo">
|
|
<span class="ml-1 text-xs text-slate-400">(pausado)</span>
|
|
</template>
|
|
</td>
|
|
<!-- Latencia -->
|
|
<td class="px-4 py-3 text-center hidden md:table-cell">
|
|
<span class="font-mono text-xs"
|
|
:class="m.ultima_lat_ms > 2000 ? 'text-red-500' : m.ultima_lat_ms > 800 ? 'text-amber-500' : 'text-slate-600'"
|
|
x-text="m.ultima_lat_ms ? formatLatencia(m.ultima_lat_ms) : '—'"></span>
|
|
</td>
|
|
<!-- Último chequeo -->
|
|
<td class="px-4 py-3 text-center hidden lg:table-cell">
|
|
<span class="text-xs text-slate-400" x-text="m.ultimo_check_at ? formatFecha(m.ultimo_check_at) : '—'"></span>
|
|
</td>
|
|
<!-- Acciones -->
|
|
<td class="px-4 py-3 text-right">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<!-- Verificar ahora -->
|
|
<button @click="verificarAhora(m)"
|
|
:disabled="checkCargando[m.ID]"
|
|
title="Verificar ahora"
|
|
class="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 transition disabled:opacity-40">
|
|
<svg class="w-4 h-4" :class="checkCargando[m.ID] && '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>
|
|
</button>
|
|
<!-- Ver historial -->
|
|
<button @click="abrirLogs(m)"
|
|
title="Ver historial"
|
|
class="p-1.5 rounded-lg text-slate-400 hover:text-purple-600 hover:bg-purple-50 transition">
|
|
<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
</svg>
|
|
</button>
|
|
<!-- Editar -->
|
|
<button @click="abrirEditar(m)"
|
|
title="Editar"
|
|
class="p-1.5 rounded-lg text-slate-400 hover:text-[#8eb02f] hover:bg-[#f5f8e8] transition">
|
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
</svg>
|
|
</button>
|
|
<!-- Eliminar -->
|
|
<button @click="eliminar(m)"
|
|
title="Eliminar"
|
|
class="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition">
|
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════ -->
|
|
<!-- Modal Crear / Editar -->
|
|
<!-- ═══════════════════════════════════════════════════ -->
|
|
<div x-show="modal.open" x-cloak
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
|
<div @click.outside="cerrarModal()"
|
|
class="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
|
<h2 class="text-lg font-bold text-slate-800"
|
|
x-text="modal.modo === 'crear' ? 'Nuevo monitor' : 'Editar monitor'"></h2>
|
|
<button @click="cerrarModal()" class="text-slate-400 hover:text-slate-600 transition">
|
|
<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="px-6 py-5 space-y-4">
|
|
<!-- Nombre -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-slate-600 mb-1">Nombre</label>
|
|
<input x-model="modal.form.nombre" type="text" placeholder="Mi sitio web"
|
|
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
|
|
</div>
|
|
<!-- URL -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-slate-600 mb-1">URL</label>
|
|
<input x-model="modal.form.url" type="url" placeholder="https://ejemplo.com"
|
|
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
|
|
</div>
|
|
<!-- Intervalo y Timeout -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-semibold text-slate-600 mb-1">Intervalo (min)</label>
|
|
<input x-model.number="modal.form.intervalo_min" type="number" min="1" max="60"
|
|
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold text-slate-600 mb-1">Timeout (seg)</label>
|
|
<input x-model.number="modal.form.timeout_seg" type="number" min="1" max="60"
|
|
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8eb02f]"/>
|
|
</div>
|
|
</div>
|
|
<!-- Activo -->
|
|
<div class="flex items-center gap-3">
|
|
<label class="relative inline-flex cursor-pointer">
|
|
<input type="checkbox" x-model="modal.form.activo" class="sr-only peer">
|
|
<div class="w-10 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-[#8eb02f] rounded-full peer
|
|
peer-checked:bg-[#8eb02f] after:content-[''] after:absolute after:top-0.5 after:left-[2px]
|
|
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
|
|
peer-checked:after:translate-x-5"></div>
|
|
</label>
|
|
<span class="text-sm text-slate-600">Monitoreo activo</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-6 py-4 border-t border-slate-100 flex justify-end gap-3">
|
|
<button @click="cerrarModal()"
|
|
class="px-4 py-2 text-sm rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 transition">
|
|
Cancelar
|
|
</button>
|
|
<button @click="guardar()" :disabled="guardando"
|
|
class="px-4 py-2 text-sm rounded-lg text-white font-medium transition disabled:opacity-50"
|
|
style="background-color:#8eb02f"
|
|
onmouseover="if(!this.disabled)this.style.backgroundColor='#6d8c24'"
|
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
|
<span x-text="guardando ? 'Guardando...' : 'Guardar'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════ -->
|
|
<!-- Panel de Historial (slide-in derecho) -->
|
|
<!-- ═══════════════════════════════════════════════════ -->
|
|
<div x-show="logs.open" x-cloak
|
|
class="fixed inset-0 z-50 flex justify-end bg-black/40">
|
|
<div @click.outside="cerrarLogs()"
|
|
class="bg-white w-full max-w-2xl h-full overflow-y-auto shadow-2xl flex flex-col">
|
|
|
|
<!-- Panel header -->
|
|
<div class="flex items-start justify-between px-6 py-4 border-b border-slate-200 sticky top-0 bg-white z-10">
|
|
<div>
|
|
<h2 class="text-base font-bold text-slate-800" x-text="logs.monitor?.nombre"></h2>
|
|
<a :href="logs.monitor?.url" target="_blank" rel="noopener"
|
|
class="text-xs text-blue-500 hover:underline font-mono" x-text="logs.monitor?.url"></a>
|
|
</div>
|
|
<button @click="cerrarLogs()" class="text-slate-400 hover:text-slate-600 transition mt-0.5 shrink-0">
|
|
<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="flex-1 px-6 py-5 space-y-5">
|
|
|
|
<!-- Range selector -->
|
|
<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="cargarLogs(opt.h)"
|
|
:class="logs.horas === 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>
|
|
|
|
<!-- Chart -->
|
|
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<template x-if="logs.cargando">
|
|
<div class="flex items-center justify-center h-36 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="!logs.cargando && logs.vacio">
|
|
<div class="flex items-center justify-center h-36 text-slate-400 text-sm">Sin datos en este período</div>
|
|
</template>
|
|
<div x-show="!logs.cargando && !logs.vacio">
|
|
<canvas id="latenciaChart" height="130"></canvas>
|
|
</div>
|
|
<div x-show="!logs.cargando && !logs.vacio" 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-green-500 inline-block"></span>OK</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-red-500 inline-block"></span>Error</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Log table -->
|
|
<div x-show="!logs.cargando && !logs.vacio">
|
|
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Últimos registros</h3>
|
|
<div class="border border-slate-200 rounded-xl overflow-hidden">
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="bg-slate-50 border-b border-slate-200">
|
|
<th class="px-3 py-2 text-left font-semibold text-slate-500">Fecha</th>
|
|
<th class="px-3 py-2 text-center font-semibold text-slate-500">Estado HTTP</th>
|
|
<th class="px-3 py-2 text-center font-semibold text-slate-500">Latencia</th>
|
|
<th class="px-3 py-2 text-center font-semibold text-slate-500">Resultado</th>
|
|
<th class="px-3 py-2 text-left font-semibold text-slate-500">Error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
<template x-for="(log, idx) in logsRecientes()" :key="log.id || idx">
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-3 py-2 font-mono text-slate-500 whitespace-nowrap"
|
|
x-text="formatFecha(log.created_at)"></td>
|
|
<td class="px-3 py-2 text-center">
|
|
<span class="font-mono font-bold"
|
|
:class="log.status_code >= 200 && log.status_code < 400 ? 'text-green-600' : 'text-red-500'"
|
|
x-text="log.status_code || '—'"></span>
|
|
</td>
|
|
<td class="px-3 py-2 text-center font-mono"
|
|
:class="log.latencia_ms > 2000 ? 'text-red-500' : log.latencia_ms > 800 ? 'text-amber-500' : 'text-slate-600'"
|
|
x-text="log.latencia_ms ? formatLatencia(log.latencia_ms) : '—'"></td>
|
|
<td class="px-3 py-2 text-center">
|
|
<span :class="log.ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
|
|
class="px-2 py-0.5 rounded-full font-semibold text-xs"
|
|
x-text="log.ok ? 'OK' : 'Error'"></span>
|
|
</td>
|
|
<td class="px-3 py-2 text-slate-400 max-w-[180px] truncate" x-text="log.error || ''"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p x-show="logs.items.length > 50" class="text-xs text-slate-400 mt-2 text-center"
|
|
x-text="'Mostrando últimos 50 de ' + logs.items.length + ' registros'"></p>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function urlMonitorApp() {
|
|
return {
|
|
items: [],
|
|
cargando: true,
|
|
errorMsg: '',
|
|
successMsg: '',
|
|
guardando: false,
|
|
checkCargando: {},
|
|
|
|
modal: {
|
|
open: false,
|
|
modo: 'crear',
|
|
editId: null,
|
|
form: { nombre: '', url: '', intervalo_min: 5, timeout_seg: 10, activo: true },
|
|
},
|
|
|
|
logs: {
|
|
open: false,
|
|
monitor: null,
|
|
horas: 24,
|
|
cargando: false,
|
|
vacio: false,
|
|
items: [],
|
|
_chart: null,
|
|
},
|
|
|
|
async init() {
|
|
await this.cargar();
|
|
setInterval(() => this.cargar(), 60000);
|
|
},
|
|
|
|
async cargar() {
|
|
try {
|
|
const r = await fetch('/app/url-monitors');
|
|
if (!r.ok) throw new Error(await r.text());
|
|
this.items = await r.json();
|
|
} catch (e) {
|
|
this.mostrarError('Error cargando monitores: ' + e.message);
|
|
} finally {
|
|
this.cargando = false;
|
|
}
|
|
},
|
|
|
|
abrirNuevo() {
|
|
this.modal.modo = 'crear';
|
|
this.modal.editId = null;
|
|
this.modal.form = { nombre: '', url: '', intervalo_min: 5, timeout_seg: 10, activo: true };
|
|
this.modal.open = true;
|
|
},
|
|
|
|
abrirEditar(m) {
|
|
this.modal.modo = 'editar';
|
|
this.modal.editId = m.ID;
|
|
this.modal.form = {
|
|
nombre: m.nombre,
|
|
url: m.url,
|
|
intervalo_min: m.intervalo_min,
|
|
timeout_seg: m.timeout_seg,
|
|
activo: m.activo,
|
|
};
|
|
this.modal.open = true;
|
|
},
|
|
|
|
cerrarModal() {
|
|
this.modal.open = false;
|
|
},
|
|
|
|
async guardar() {
|
|
if (!this.modal.form.nombre.trim() || !this.modal.form.url.trim()) {
|
|
this.mostrarError('Nombre y URL son requeridos');
|
|
return;
|
|
}
|
|
this.guardando = true;
|
|
try {
|
|
const esEditar = this.modal.modo === 'editar';
|
|
const url = esEditar ? `/app/url-monitor/${this.modal.editId}` : '/app/url-monitor';
|
|
const r = await fetch(url, {
|
|
method: esEditar ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.modal.form),
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json();
|
|
throw new Error(err.error || 'Error al guardar');
|
|
}
|
|
this.mostrarExito(esEditar ? 'Monitor actualizado' : 'Monitor creado');
|
|
this.cerrarModal();
|
|
await this.cargar();
|
|
} catch (e) {
|
|
this.mostrarError(e.message);
|
|
} finally {
|
|
this.guardando = false;
|
|
}
|
|
},
|
|
|
|
async eliminar(m) {
|
|
if (!confirm(`¿Eliminar el monitor "${m.nombre}"?`)) return;
|
|
try {
|
|
const r = await fetch(`/app/url-monitor/${m.ID}`, { method: 'DELETE' });
|
|
if (!r.ok) throw new Error('Error al eliminar');
|
|
this.mostrarExito('Monitor eliminado');
|
|
await this.cargar();
|
|
} catch (e) {
|
|
this.mostrarError(e.message);
|
|
}
|
|
},
|
|
|
|
async verificarAhora(m) {
|
|
this.checkCargando = { ...this.checkCargando, [m.ID]: true };
|
|
try {
|
|
const r = await fetch(`/app/url-monitor/${m.ID}/check`, { method: 'POST' });
|
|
if (!r.ok) throw new Error('Error al verificar');
|
|
this.mostrarExito('Verificación iniciada, espera unos segundos...');
|
|
setTimeout(() => this.cargar(), 4000);
|
|
} catch (e) {
|
|
this.mostrarError(e.message);
|
|
} finally {
|
|
this.checkCargando = { ...this.checkCargando, [m.ID]: false };
|
|
}
|
|
},
|
|
|
|
async abrirLogs(m) {
|
|
this.logs.monitor = m;
|
|
this.logs.horas = 24;
|
|
this.logs.items = [];
|
|
this.logs.vacio = false;
|
|
if (this.logs._chart) { this.logs._chart.destroy(); this.logs._chart = null; }
|
|
this.logs.open = true;
|
|
await this.cargarLogs(24);
|
|
},
|
|
|
|
cerrarLogs() {
|
|
if (this.logs._chart) { this.logs._chart.destroy(); this.logs._chart = null; }
|
|
this.logs.open = false;
|
|
this.logs.monitor = null;
|
|
},
|
|
|
|
async cargarLogs(horas) {
|
|
if (!this.logs.monitor) return;
|
|
this.logs.horas = horas;
|
|
this.logs.cargando = true;
|
|
this.logs.vacio = false;
|
|
if (this.logs._chart) { this.logs._chart.destroy(); this.logs._chart = null; }
|
|
try {
|
|
const r = await fetch(`/app/url-monitor/${this.logs.monitor.ID}/logs?horas=${horas}`);
|
|
if (!r.ok) throw new Error('Error cargando logs');
|
|
const data = await r.json();
|
|
this.logs.items = data || [];
|
|
if (!data || data.length === 0) { this.logs.vacio = true; return; }
|
|
|
|
const pts = data.length > 200
|
|
? data.filter((_, i) => i % Math.ceil(data.length / 200) === 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('latenciaChart');
|
|
if (!canvas) return;
|
|
this.logs._chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'Latencia ms',
|
|
data: pts.map(p => p.latencia_ms),
|
|
backgroundColor: pts.map(p => p.ok ? 'rgba(34,197,94,0.7)' : 'rgba(239,68,68,0.7)'),
|
|
borderColor: pts.map(p => p.ok ? '#16a34a' : '#dc2626'),
|
|
borderWidth: 1,
|
|
borderRadius: 2,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: ctx => this.formatLatencia(ctx.raw) + (pts[ctx.dataIndex]?.ok ? ' ✓' : ' ✗'),
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } }, grid: { display: false } },
|
|
y: {
|
|
ticks: {
|
|
font: { size: 10 },
|
|
callback: v => v >= 1000 ? (v / 1000).toFixed(1) + 's' : v + 'ms',
|
|
},
|
|
grid: { color: 'rgba(0,0,0,0.04)' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
this.mostrarError(e.message);
|
|
this.logs.vacio = true;
|
|
} finally {
|
|
this.logs.cargando = false;
|
|
}
|
|
},
|
|
|
|
logsRecientes() {
|
|
return [...this.logs.items].reverse().slice(0, 50);
|
|
},
|
|
|
|
avgLatencia() {
|
|
const activos = this.items.filter(m => m.ultima_lat_ms > 0);
|
|
if (!activos.length) return '—';
|
|
const avg = activos.reduce((s, m) => s + m.ultima_lat_ms, 0) / activos.length;
|
|
return this.formatLatencia(Math.round(avg));
|
|
},
|
|
|
|
formatLatencia(ms) {
|
|
if (!ms) return '—';
|
|
return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : ms + 'ms';
|
|
},
|
|
|
|
formatFecha(ts) {
|
|
if (!ts) return '—';
|
|
const d = new Date(ts);
|
|
const now = new Date();
|
|
const diff = Math.floor((now - d) / 1000);
|
|
if (diff < 60) return 'hace ' + diff + 's';
|
|
if (diff < 3600) return 'hace ' + Math.floor(diff / 60) + 'min';
|
|
if (diff < 86400) return 'hace ' + Math.floor(diff / 3600) + 'h';
|
|
return d.toLocaleDateString('es', { day: '2-digit', month: 'short' }) + ' ' +
|
|
d.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' });
|
|
},
|
|
|
|
mostrarError(msg) {
|
|
this.errorMsg = msg;
|
|
this.successMsg = '';
|
|
setTimeout(() => this.errorMsg = '', 5000);
|
|
},
|
|
|
|
mostrarExito(msg) {
|
|
this.successMsg = msg;
|
|
this.errorMsg = '';
|
|
setTimeout(() => this.successMsg = '', 4000);
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
</div>
|