Initial commit
This commit is contained in:
917
mcp-server/monitor.html
Normal file
917
mcp-server/monitor.html
Normal 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()">← 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>
|
||||
Reference in New Issue
Block a user