Files
soft_usite/resources/views/url_monitor.html
T
Lizandro GuarnizoandClaude Sonnet 4.6 d0ba75c559 feat(url-monitor): agregar vista con tabla, modal CRUD e historial de latencia
- 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>
2026-07-06 09:47:44 -05:00

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>