918 lines
34 KiB
HTML
918 lines
34 KiB
HTML
<!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()">← 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 = { "&": "&", "<": "<", ">": ">" };
|
|
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>
|