Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit bc4199aed2
201 changed files with 25612 additions and 0 deletions

917
mcp-server/monitor.html Normal file
View File

@@ -0,0 +1,917 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monitor MCP</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, "SF Pro Display", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #0f172a;
color: #e2e8f0;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #0f172a;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
main {
flex: 1;
display: grid;
grid-template-columns: minmax(260px, 360px) 1fr;
gap: 1px;
background: #1e293b;
}
section {
background: #0f172a;
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.list-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #94a3b8;
}
.request-list {
flex: 1;
overflow-y: auto;
}
.request-item {
padding: 0.85rem 1rem;
border-bottom: 1px solid #1e293b;
cursor: pointer;
transition: background 0.2s ease;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.request-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.request-item.active {
background: rgba(59, 130, 246, 0.15);
border-left: 3px solid #3b82f6;
padding-left: calc(1rem - 3px);
}
.request-top {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.request-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #94a3b8;
}
.status {
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status.pending {
background: rgba(251, 191, 36, 0.15);
color: #facc15;
}
.status.success {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.status.error {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.details {
display: flex;
flex-direction: column;
}
.details-header {
border-bottom: 1px solid #1e293b;
padding: 0.75rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.details-content {
flex: 1;
overflow: auto;
padding: 1.25rem;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1rem;
}
.detail-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid #1e293b;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-card h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: #f8fafc;
}
pre {
margin: 0;
padding: 0.75rem;
background: #020617;
border-radius: 0.5rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.4;
border: 1px solid #1e293b;
}
code {
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
}
.pill {
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
background: rgba(148, 163, 184, 0.2);
color: #cbd5f5;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: #22c55e;
}
.dot.offline {
background: #ef4444;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-left: 2rem;
}
.tab {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #94a3b8;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.tab:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
}
.tab.active {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: flex;
flex: 1;
}
/* Sessions list */
.session-item {
padding: 1rem;
border-bottom: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.session-website {
font-weight: 600;
color: #f8fafc;
font-size: 1rem;
}
.session-website.no-website {
color: #94a3b8;
font-style: italic;
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: #94a3b8;
}
.session-id {
font-family: "JetBrains Mono", monospace;
background: rgba(148, 163, 184, 0.1);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.session-status {
display: flex;
align-items: center;
gap: 0.35rem;
}
.session-status .dot {
width: 0.5rem;
height: 0.5rem;
}
.sessions-summary {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
background: rgba(59, 130, 246, 0.05);
font-size: 0.85rem;
color: #94a3b8;
}
/* Stats tab */
.stats-session-card {
border: 1px solid #1e293b;
border-radius: 0.75rem;
margin: 1rem;
overflow: hidden;
}
.stats-session-header {
padding: 1rem;
background: rgba(59, 130, 246, 0.08);
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.stats-session-header h3 {
margin: 0;
font-size: 1rem;
color: #f8fafc;
}
.stats-totals {
display: flex;
gap: 1.5rem;
font-size: 0.85rem;
color: #94a3b8;
}
.stats-totals .stat-value {
color: #e2e8f0;
font-weight: 600;
}
.tool-breakdown-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.tool-breakdown-table th {
text-align: left;
padding: 0.6rem 1rem;
color: #94a3b8;
font-weight: 500;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
border-bottom: 1px solid #1e293b;
}
.tool-breakdown-table td {
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(30, 41, 59, 0.5);
color: #e2e8f0;
}
.tool-breakdown-table tr:hover {
background: rgba(59, 130, 246, 0.05);
}
.chars-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
background: #3b82f6;
min-width: 2px;
vertical-align: middle;
margin-right: 0.4rem;
}
.chars-bar.out {
background: #22c55e;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
}
.details {
min-height: 50vh;
}
.tabs {
margin-left: 0;
margin-top: 0.5rem;
}
header {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<header>
<div style="display: flex; align-items: center;">
<div>
<h1 style="margin: 0; font-size: 1.25rem;">Monitor MCP</h1>
<p style="margin: 0; font-size: 0.85rem; color: #94a3b8;">Seguimiento en tiempo real</p>
</div>
<div class="tabs">
<button class="tab active" data-tab="requests" onclick="switchTab('requests')">Peticiones</button>
<button class="tab" data-tab="sessions" onclick="switchTab('sessions')">Sesiones <span id="sessionsCount" style="opacity:0.7">(0)</span></button>
<button class="tab" data-tab="stats" onclick="switchTab('stats')">Stats</button>
</div>
</div>
<div class="connection-status">
<span class="dot" id="conn-dot"></span>
<span id="conn-label">Conectando…</span>
</div>
</header>
<main>
<!-- Tab: Peticiones -->
<div class="tab-content active" id="tab-requests" style="display: contents;">
<section class="list">
<div class="list-header">Peticiones recientes</div>
<div class="request-list" id="requestList"></div>
</section>
<section class="details">
<div class="details-header">
<div>
<strong id="detailMethod"></strong>
<span class="pill" id="detailStatus">Sin selección</span>
</div>
<div style="font-size: 0.85rem; color: #94a3b8;" id="detailTimestamp"></div>
</div>
<div class="details-content" id="detailContent">
<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>
</div>
</section>
</div>
<!-- Tab: Sesiones -->
<div class="tab-content" id="tab-sessions" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column;">
<div class="sessions-summary" id="sessionsSummary">
Cargando sesiones...
</div>
<div class="request-list" id="sessionsList" style="flex: 1;"></div>
</section>
</div>
<!-- Tab: Stats -->
<div class="tab-content" id="tab-stats" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column; overflow-y: auto;">
<div class="sessions-summary" id="statsSummary">
Cargando estadisticas...
</div>
<div id="statsContent" style="flex: 1;"></div>
</section>
</div>
</main>
<script>
const requestListEl = document.getElementById("requestList");
const detailMethodEl = document.getElementById("detailMethod");
const detailStatusEl = document.getElementById("detailStatus");
const detailTimestampEl = document.getElementById("detailTimestamp");
const detailContentEl = document.getElementById("detailContent");
const connDot = document.getElementById("conn-dot");
const connLabel = document.getElementById("conn-label");
const sessionsListEl = document.getElementById("sessionsList");
const sessionsSummaryEl = document.getElementById("sessionsSummary");
const sessionsCountEl = document.getElementById("sessionsCount");
const summaries = new Map();
let sessions = [];
let sessionStats = [];
let selectedId = null;
let currentTab = "requests";
let filterSessionId = null;
function formatChars(chars) {
if (chars == null || chars === 0) return "0";
if (chars < 1000) return `${chars}`;
if (chars < 1000000) return `${(chars / 1000).toFixed(1)}K`;
return `${(chars / 1000000).toFixed(2)}M`;
}
function estimateTokens(chars) {
if (chars == null || chars === 0) return 0;
return Math.ceil(chars / 4);
}
function formatTokens(chars) {
const tokens = estimateTokens(chars);
if (tokens < 1000) return `~${tokens}`;
if (tokens < 1000000) return `~${(tokens / 1000).toFixed(1)}K`;
return `~${(tokens / 1000000).toFixed(2)}M`;
}
// Tab switching
function switchTab(tabName) {
currentTab = tabName;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
document.getElementById('tab-requests').style.display = tabName === 'requests' ? 'contents' : 'none';
document.getElementById('tab-sessions').style.display = tabName === 'sessions' ? 'flex' : 'none';
document.getElementById('tab-stats').style.display = tabName === 'stats' ? 'flex' : 'none';
if (tabName === 'sessions') renderSessions();
if (tabName === 'stats') renderStats();
}
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function formatDuration(ms) {
if (ms == null) return "—";
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
}
function setConnectionStatus(online) {
connDot.classList.toggle("offline", !online);
connLabel.textContent = online ? "Tiempo real conectado" : "Conexión perdida";
}
function formatDurationLong(ms) {
if (ms == null) return "—";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
function renderSessions() {
sessionsCountEl.textContent = `(${sessions.length})`;
if (!sessions.length) {
sessionsSummaryEl.textContent = "No hay sesiones activas";
sessionsListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin sesiones activas.</div>';
return;
}
// Group by website
const byWebsite = {};
sessions.forEach(s => {
const key = s.website || '(sin website)';
if (!byWebsite[key]) byWebsite[key] = [];
byWebsite[key].push(s);
});
const websiteCount = Object.keys(byWebsite).length;
sessionsSummaryEl.textContent = `${sessions.length} sesión${sessions.length !== 1 ? 'es' : ''} activa${sessions.length !== 1 ? 's' : ''} en ${websiteCount} website${websiteCount !== 1 ? 's' : ''}`;
sessionsListEl.innerHTML = "";
sessions.forEach((session) => {
const div = document.createElement("div");
div.className = "session-item";
const websiteClass = session.website ? '' : 'no-website';
const websiteDisplay = session.website || '(sin credenciales)';
div.style.cursor = "pointer";
div.addEventListener("click", () => filterBySession(session.sessionId));
div.innerHTML = `
<div class="session-website ${websiteClass}">${websiteDisplay} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></div>
<div class="session-meta">
<span class="session-status">
<span class="dot" style="background: ${session.hasToken ? '#22c55e' : '#f59e0b'}"></span>
${session.hasToken ? 'Autenticado' : 'Pendiente auth'}
</span>
<span>Duración: ${formatDurationLong(session.durationMs)}</span>
${session.profileName ? `<span>Perfil: ${session.profileName}</span>` : ''}
</div>
<div class="session-meta">
<span class="session-id">${session.sessionId}</span>
<span>Iniciada: ${formatDate(session.startTime)}</span>
</div>
`;
sessionsListEl.appendChild(div);
});
}
function renderStats() {
const statsSummaryEl = document.getElementById("statsSummary");
const statsContentEl = document.getElementById("statsContent");
if (!sessionStats.length) {
statsSummaryEl.textContent = "No hay estadisticas disponibles";
statsContentEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin datos de herramientas todavia.</div>';
return;
}
let totalTools = 0, totalReqChars = 0, totalResChars = 0;
sessionStats.forEach(s => {
totalTools += s.totalToolCalls;
totalReqChars += s.totalRequestChars;
totalResChars += s.totalResponseChars;
});
statsSummaryEl.textContent = `${sessionStats.length} sesion${sessionStats.length !== 1 ? 'es' : ''} | ${totalTools} tool calls | In: ${formatTokens(totalReqChars)} tokens (${formatChars(totalReqChars)} chars) | Out: ${formatTokens(totalResChars)} tokens (${formatChars(totalResChars)} chars)`;
statsContentEl.innerHTML = "";
let maxChars = 0;
sessionStats.forEach(s => {
Object.values(s.toolBreakdown).forEach(tb => {
const total = tb.requestChars + tb.responseChars;
if (total > maxChars) maxChars = total;
});
});
sessionStats.forEach(stat => {
const card = document.createElement("div");
card.className = "stats-session-card";
const sessionInfo = sessions.find(s => s.sessionId === stat.sessionId);
const websiteName = sessionInfo?.website || stat.sessionId.substring(0, 16) + "...";
const toolEntries = Object.entries(stat.toolBreakdown).sort((a, b) => (b[1].responseChars + b[1].requestChars) - (a[1].responseChars + a[1].requestChars));
let toolRowsHtml = "";
toolEntries.forEach(([name, tb]) => {
const reqBarWidth = maxChars > 0 ? Math.max(2, (tb.requestChars / maxChars) * 120) : 2;
const resBarWidth = maxChars > 0 ? Math.max(2, (tb.responseChars / maxChars) * 120) : 2;
const totalToolChars = tb.requestChars + tb.responseChars;
toolRowsHtml += `<tr>
<td style="font-family: 'JetBrains Mono', monospace; font-size: 0.8rem;">${name}</td>
<td style="text-align:center;">${tb.count}</td>
<td><span class="chars-bar" style="width:${reqBarWidth}px"></span>${formatChars(tb.requestChars)}</td>
<td><span class="chars-bar out" style="width:${resBarWidth}px"></span>${formatChars(tb.responseChars)}</td>
<td style="text-align:center;color:#a78bfa;font-weight:600;">${formatTokens(totalToolChars)}</td>
<td style="text-align:center;">${formatDuration(tb.avgDurationMs)}</td>
<td style="text-align:center; color: ${tb.errors > 0 ? '#f87171' : '#4ade80'};">${tb.errors}</td>
</tr>`;
});
card.style.cursor = "pointer";
card.addEventListener("click", () => filterBySession(stat.sessionId));
card.innerHTML = `
<div class="stats-session-header">
<h3>${websiteName} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></h3>
<div class="stats-totals">
<span>Requests: <span class="stat-value">${stat.totalRequests}</span></span>
<span>Tool calls: <span class="stat-value">${stat.totalToolCalls}</span></span>
<span>In: <span class="stat-value">${formatTokens(stat.totalRequestChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalRequestChars)} chars)</span></span>
<span>Out: <span class="stat-value">${formatTokens(stat.totalResponseChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalResponseChars)} chars)</span></span>
<span>Errors: <span class="stat-value" style="color: ${stat.errorCount > 0 ? '#f87171' : '#4ade80'};">${stat.errorCount}</span></span>
</div>
</div>
${toolEntries.length > 0 ? `
<table class="tool-breakdown-table">
<thead><tr>
<th>Tool</th>
<th style="text-align:center;">Calls</th>
<th>Chars In</th>
<th>Chars Out</th>
<th style="text-align:center;">Est. Tokens</th>
<th style="text-align:center;">Avg Time</th>
<th style="text-align:center;">Errors</th>
</tr></thead>
<tbody>${toolRowsHtml}</tbody>
</table>` : '<div style="padding:1rem;color:#94a3b8;">No tool calls in this session.</div>'}
`;
statsContentEl.appendChild(card);
});
}
function filterBySession(sessionId) {
filterSessionId = sessionId;
selectedId = null;
detailMethodEl.textContent = "—";
detailStatusEl.textContent = "Sin selección";
detailStatusEl.className = "pill";
detailTimestampEl.textContent = "";
detailContentEl.innerHTML = '<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>';
switchTab('requests');
renderList();
}
function clearFilter() {
filterSessionId = null;
renderList();
}
function renderList() {
let items = Array.from(summaries.values()).sort((a, b) => {
return new Date(b.timestamp) - new Date(a.timestamp);
});
if (filterSessionId) {
items = items.filter(i => i.sessionId === filterSessionId);
}
// Update list header with filter info
const listHeaderEl = document.querySelector('.list-header');
if (filterSessionId) {
const sessionInfo = sessions.find(s => s.sessionId === filterSessionId);
const label = sessionInfo?.website || filterSessionId.substring(0, 12) + '...';
// Compute session-level stats for the filter banner
const stat = sessionStats.find(s => s.sessionId === filterSessionId);
const statsLine = stat ? ` | ${stat.totalToolCalls} calls | ${formatTokens(stat.totalRequestChars + stat.totalResponseChars)} tokens` : '';
listHeaderEl.innerHTML = `<span style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<span style="color:#3b82f6;cursor:pointer;" onclick="clearFilter()">&#x2190; Todas</span>
<span>${label}${statsLine}</span>
<span style="opacity:0.6;">(${items.length} req)</span>
</span>`;
} else {
listHeaderEl.textContent = 'Peticiones recientes';
}
if (!items.length) {
requestListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin peticiones todavía.</div>';
return;
}
requestListEl.innerHTML = "";
items.forEach((item) => {
const div = document.createElement("div");
div.className = "request-item";
if (item.id === selectedId) {
div.classList.add("active");
}
const toolLabel = item.toolName ? ` <span style="color:#94a3b8;font-weight:400;font-size:0.85rem;">${item.toolName}</span>` : "";
const charsLabel = item.responseChars ? ` · ${formatChars(item.responseChars)} chars` : "";
div.innerHTML = `
<div class="request-top">
<span>${item.method}${toolLabel}</span>
<span class="status ${item.status}">${item.status}</span>
</div>
<div class="request-meta">
<span>${formatDate(item.timestamp)}</span>
<span>${formatDuration(item.durationMs)}${charsLabel}</span>
</div>
${item.errorMessage ? `<div style="color:#f87171;font-size:0.8rem;">${item.errorMessage}</div>` : ""}
`;
div.addEventListener("click", () => selectRequest(item.id));
requestListEl.appendChild(div);
});
}
async function selectRequest(id) {
selectedId = id;
renderList();
detailContentEl.innerHTML = '<div class="empty-state" style="justify-content:flex-start;align-items:flex-start;">Cargando detalle…</div>';
try {
const response = await fetch(`/requests/${id}`);
if (!response.ok) {
throw new Error("No se pudo cargar el detalle");
}
const detail = await response.json();
renderDetails(detail);
} catch (error) {
detailContentEl.innerHTML = `<div class="empty-state" style="color:#f87171;">${error.message}</div>`;
}
}
function renderDetails(detail) {
detailMethodEl.textContent = detail.method;
detailStatusEl.textContent = detail.status.toUpperCase();
detailStatusEl.className = `pill status ${detail.status}`;
detailTimestampEl.textContent = `${formatDate(detail.timestamp)} · ${formatDuration(detail.durationMs)}`;
const requestPayload = syntaxHighlight(detail.request);
const responsePayload = syntaxHighlight(detail.response ?? detail.error);
const isToolCall = detail.method === "tools/call";
const retryButton = isToolCall
? `<button onclick="retryRequest('${detail.id}')" style="margin-left:auto; padding:0.3rem 0.8rem; background:#3b82f6; border:none; border-radius:4px; color:white; cursor:pointer; font-size:0.85rem;">Reenviar</button>`
: "";
// Inject button into header
const headerActions = document.createElement('div');
headerActions.style.display = 'flex';
headerActions.style.alignItems = 'center';
headerActions.style.gap = '1rem';
headerActions.innerHTML = retryButton;
// Clear previous actions if any (hacky but works for this simple UI)
// We'll just append it to detail-header
detailContentEl.innerHTML = `
<div class="detail-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
<h2 style="margin:0;">Contexto</h2>
${retryButton}
</div>
<div style="font-size:0.85rem; color:#94a3b8; display:flex; flex-wrap:wrap; gap:1rem;">
<span>Session ID: <code>${detail.sessionId ?? "—"}</code></span>
${detail.toolName ? `<span>Tool: <code>${detail.toolName}</code></span>` : ""}
<span>Chars in: <code>${formatChars(detail.requestChars || 0)}</code></span>
<span>Chars out: <code>${formatChars(detail.responseChars || 0)}</code></span>
<span style="color:#a78bfa;font-weight:600;">Est. tokens: <code>${formatTokens((detail.requestChars || 0) + (detail.responseChars || 0))}</code></span>
</div>
</div>
<div class="detail-card">
<h2>Payload recibido</h2>
<pre><code>${requestPayload}</code></pre>
</div>
<div class="detail-card">
<h2>${detail.status === "error" ? "Error devuelto" : "Respuesta enviada"}</h2>
<pre><code>${responsePayload}</code></pre>
</div>
`;
}
async function retryRequest(id) {
if (!confirm("¿Estás seguro de que quieres reenviar esta petición?")) return;
try {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = true;
btn.textContent = "Reenviando...";
}
const response = await fetch(`/retry/${id}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert("Petición reenviada con éxito. Se ha generado una nueva entrada en el monitor.");
// The SSE will update the list automatically
} else {
alert("Error al reenviar: " + (result.error || "Error desconocido"));
}
} catch (error) {
alert("Error de red: " + error.message);
} finally {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = false;
btn.textContent = "Reenviar";
}
}
}
function syntaxHighlight(data) {
if (data == null) {
return "—";
}
const json = JSON.stringify(data, null, 2);
return json.replace(/(&|<|>)/g, (char) => {
const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
return map[char];
});
}
async function bootstrap() {
const [requestsRes, sessionsRes, statsRes] = await Promise.all([
fetch("/requests"),
fetch("/sessions"),
fetch("/stats")
]);
if (requestsRes.ok) {
const { requests } = await requestsRes.json();
requests.forEach((item) => summaries.set(item.id, item));
renderList();
}
if (sessionsRes.ok) {
const data = await sessionsRes.json();
sessions = data.sessions || [];
renderSessions();
}
if (statsRes.ok) {
const data = await statsRes.json();
sessionStats = data.stats || [];
}
}
function initSSE() {
const source = new EventSource("/events");
source.addEventListener("open", () => setConnectionStatus(true));
source.addEventListener("error", () => setConnectionStatus(false));
source.addEventListener("bootstrap", (event) => {
const data = JSON.parse(event.data);
(data.requests || []).forEach((item) => summaries.set(item.id, item));
sessions = data.sessions || [];
sessionStats = data.stats || [];
renderList();
renderSessions();
if (currentTab === 'stats') renderStats();
});
source.addEventListener("summary", (event) => {
const data = JSON.parse(event.data);
summaries.set(data.id, data);
renderList();
if (selectedId === data.id) {
selectRequest(data.id);
}
});
source.addEventListener("sessions", (event) => {
const data = JSON.parse(event.data);
sessions = data.sessions || [];
renderSessions();
});
source.addEventListener("stats", (event) => {
const data = JSON.parse(event.data);
sessionStats = data.stats || [];
if (currentTab === 'stats') renderStats();
});
}
bootstrap();
initSSE();
</script>
</body>
</html>