feat: MCP Tool Tester UI/UX 고도화 (Stress 테스트, 실시간 로그 콘솔, Auto-Ping 등 추가)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s

This commit is contained in:
jade
2026-08-05 10:59:40 +09:00
parent 4b34df774d
commit bf369e2a83

View File

@@ -4,87 +4,73 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tool Auto Tester</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/lib/tailwindcss.js"></script>
<script src="/lib/chart.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { font-family: 'Geist', sans-serif; }
body { background-color: #09090b; color: #f4f4f5; }
.geist-mono { font-family: 'Geist Mono', monospace; }
.btn-execute {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #ffffff;
font-weight: 600;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.btn-execute { background: linear-gradient(135deg, #3b82f6, #6366f1); color: #ffffff; font-weight: 600; padding: 10px 24px; border-radius: 8px; font-size: 14px; border: none; cursor: pointer; transition: all 0.2s; box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); }
.btn-execute:hover { box-shadow: 0 0 30px rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
.btn-execute:disabled { background: #27272a; color: #52525b; box-shadow: none; transform: none; cursor: not-allowed; }
.btn-sm {
background: #27272a;
color: #d4d4d8;
font-weight: 500;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
border: 1px solid #3f3f46;
cursor: pointer;
transition: background 0.1s;
}
.btn-cancel { background: #ef4444; color: white; font-weight: 600; padding: 10px 24px; border-radius: 8px; font-size: 14px; border: none; cursor: pointer; transition: all 0.2s; box-shadow: 0 0 20px rgba(239, 68, 68, 0.3); }
.btn-cancel:hover { box-shadow: 0 0 30px rgba(239, 68, 68, 0.5); }
.btn-sm { background: #27272a; color: #d4d4d8; font-weight: 500; padding: 6px 14px; border-radius: 6px; font-size: 13px; border: 1px solid #3f3f46; cursor: pointer; transition: background 0.1s; }
.btn-sm:hover { background: #3f3f46; color: white; }
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-stress { background: rgba(239, 68, 68, 0.1); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); font-weight: 500; padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: background 0.1s; }
.btn-stress:hover { background: rgba(239, 68, 68, 0.2); }
.btn-stress:disabled { opacity: 0.5; cursor: not-allowed; }
.card { background: #18181b; border: 1px solid #3f3f46; border-radius: 12px; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { text-align: left; padding: 12px 16px; border-bottom: 1px solid #3f3f46; color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.table td { padding: 12px 16px; border-bottom: 1px solid #27272a; vertical-align: middle; }
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th { text-align: left; padding: 16px 20px; border-bottom: 1px solid #3f3f46; color: #a1a1aa; font-weight: 500; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; }
.table td { padding: 16px 20px; border-bottom: 1px solid #27272a; vertical-align: middle; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background-color: rgba(255,255,255,0.02); }
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.status-badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; letter-spacing: 0.02em; }
.status-idle { background: rgba(113, 113, 122, 0.15); color: #a1a1aa; border: 1px solid rgba(113, 113, 122, 0.3); }
.status-running { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
.status-success { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.3); }
.status-error { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.cat-badge {
font-family: 'Geist Mono', monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: #27272a;
color: #a1a1aa;
border: 1px solid #3f3f46;
}
.cat-badge { font-family: 'Geist Mono', monospace; font-size: 12px; padding: 4px 8px; border-radius: 6px; background: #27272a; color: #a1a1aa; border: 1px solid #3f3f46; }
/* Modal Styles */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; }
.modal-content { background: #18181b; border: 1px solid #3f3f46; border-radius: 12px; width: 95%; max-width: 900px; max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); }
.modal-header { padding: 20px 28px; border-b: 1px solid #3f3f46; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 28px; overflow-y: auto; flex-grow: 1; }
.modal-footer { padding: 20px 28px; border-t: 1px solid #3f3f46; background: #09090b; flex-shrink: 0; display: flex; justify-content: flex-end; align-items: center; gap: 12px; }
.code-textarea { width: 100%; height: 400px; background: #09090b; border: 1px solid #3f3f46; border-radius: 8px; color: #a1a1aa; padding: 16px; font-size: 14px; font-family: 'Geist Mono', monospace; resize: vertical; }
.code-textarea:focus { outline: none; border-color: #3b82f6; color: #f4f4f5; }
.code-viewer { background: #09090b; border: 1px solid #3f3f46; border-radius: 8px; color: #a1a1aa; padding: 16px; font-size: 14px; font-family: 'Geist Mono', monospace; overflow: auto; max-height: 500px; white-space: pre-wrap; }
.progress-bar-bg { width: 100%; height: 6px; background: #27272a; border-radius: 3px; overflow: hidden; margin-top: 10px; display: none; }
.progress-bar-fill { height: 100%; background: #3b82f6; width: 0%; transition: width 0.3s ease; }
.tab-btn { background: transparent; border: none; color: #a1a1aa; padding: 6px 12px; font-size: 13px; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; }
.tab-btn.active { color: #3b82f6; border-bottom: 2px solid #3b82f6; }
</style>
</head>
<body class="min-h-screen" style="overflow-y: scroll;">
<header style="border-bottom: 1px solid #27272a; background: rgba(9,9,11,0.85); backdrop-filter: blur(16px);" class="sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center space-x-5">
<div class="max-w-[1400px] mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-center space-x-6">
<a href="/index.html" class="flex items-center group">
<div class="w-2 h-2 rounded-full mr-2" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
<span class="font-semibold tracking-tight text-sm" style="color:#f4f4f5;">AXHUB Gateway</span>
<div class="w-2 h-2 rounded-full mr-3" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
<span class="font-bold tracking-tight text-base" style="color:#f4f4f5;">AXHUB Gateway</span>
</a>
<div class="h-4 w-px" style="background:#27272a;"></div>
<nav class="flex space-x-5 text-[13px] font-medium">
<div class="h-5 w-px" style="background:#27272a;"></div>
<nav class="flex space-x-6 text-[14px] font-medium">
<a href="/admin/scaffold.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Scaffold</a>
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
@@ -99,28 +85,66 @@
</div>
</header>
<main class="max-w-5xl mx-auto px-6 py-10">
<div class="flex items-center justify-between mb-8">
<main class="max-w-[1400px] mx-auto px-6 py-12">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold tracking-tight mb-2" style="color:#f4f4f5;">Auto-Tester Dashboard</h1>
<p class="text-sm" style="color:#a1a1aa;">Batch execute all registered tools with auto-generated dummy data to verify stability.</p>
<h1 class="text-3xl font-bold tracking-tight mb-3" style="color:#f4f4f5;">Auto-Tester Dashboard</h1>
<p class="text-base" style="color:#a1a1aa;">Batch execute registered tools, customize payloads, and view detailed results.</p>
</div>
<div class="flex items-center gap-3">
<button id="exportCsvBtn" class="btn-sm flex items-center gap-2 h-10 px-4 text-sm" onclick="exportCsv()" style="display: none;">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Export CSV
</button>
<button id="runAllBtn" class="btn-execute flex items-center gap-2" onclick="runAllTests()">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<button id="runFailedBtn" class="btn-sm flex items-center gap-2 h-10 px-4 text-sm" onclick="runAllTests(true)" style="display: none; background:#3f3f46;">
Run Failed Only
</button>
<button id="cancelBtn" class="btn-cancel flex items-center gap-2" onclick="cancelTests()" style="display: none;">
Stop Tests
</button>
<button id="runAllBtn" class="btn-execute flex items-center gap-2" onclick="runAllTests(false)">
Run All Tests
</button>
</div>
</div>
<div class="progress-bar-bg" id="progressBarBg"><div class="progress-bar-fill" id="progressBarFill"></div></div>
<div class="flex gap-4 mb-6 mt-6">
<input type="text" id="searchInput" placeholder="Search by tool name..." class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 outline-none" oninput="applyFilters()">
<select id="categoryFilter" class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-48 p-2.5 outline-none" onchange="applyFilters()">
<option value="">All Categories</option>
</select>
</div>
<div id="chartContainer" class="card mb-6 p-6 shadow-2xl" style="display:none; position:relative; background: radial-gradient(circle at top right, rgba(59,130,246,0.1), transparent 40%), #18181b; border: 1px solid rgba(63,63,70,0.8);">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: #e4e4e7; letter-spacing: 0.05em; text-transform: uppercase;">
<div class="w-2 h-2 rounded-full" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
Execution Latency Analysis
</h3>
</div>
<div style="height:300px; position:relative;">
<canvas id="latencyChart"></canvas>
</div>
</div>
<div id="logContainer" class="card mb-6 p-4 shadow-inner" style="background:#09090b; border: 1px solid rgba(63,63,70,0.5);">
<div class="flex items-center justify-between mb-2 pb-2" style="border-bottom: 1px dashed rgba(63,63,70,0.5);">
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: #a1a1aa; letter-spacing: 0.05em; text-transform: uppercase;">
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M4 17h16M4 7h16"></path></svg>
Real-time Execution Console
</h3>
<button onclick="clearLogs()" class="text-xs text-zinc-500 hover:text-white transition">Clear Logs</button>
</div>
<div id="logViewer" class="font-mono text-xs overflow-y-auto" style="height: 150px; color: #d4d4d8; line-height: 1.6;">
<div style="color: #71717a;">[System] Console initialized. Ready to execute tools.</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="bg-[#09090b] px-6 py-4 border-b border-[#27272a] flex items-center justify-between">
<div class="text-sm font-semibold text-slate-300">Tool List</div>
<div class="text-xs text-slate-500 geist-mono" id="progressInfo">Total: 0 / Completed: 0</div>
<div class="bg-[#09090b] px-8 py-5 border-b border-[#27272a] flex items-center justify-between">
<div class="text-base font-semibold text-slate-300">Tool List <span id="visibleCount" class="text-sm text-slate-500 ml-2"></span></div>
<div class="text-sm text-slate-500 geist-mono" id="progressInfo">Total: 0 / Completed: 0</div>
</div>
<div class="overflow-x-auto">
<table class="table" id="toolsTable">
@@ -128,86 +152,244 @@
<tr>
<th style="width:10%">Category</th>
<th style="width:25%">Tool Name</th>
<th style="width:25%">Auto-Generated Payload</th>
<th style="width:20%">Status</th>
<th style="width:20%" class="text-right">Action</th>
<th style="width:25%">Payload</th>
<th style="width:18%">Status</th>
<th style="width:22%" class="text-right">Action</th>
</tr>
</thead>
<tbody id="toolsTbody">
<tr>
<td colspan="5" class="text-center text-zinc-500 py-8">Loading tools...</td>
</tr>
<tr><td colspan="5" class="text-center text-zinc-500 py-8">Loading tools...</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- Modal for Editing Payload -->
<div id="payloadModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<div>
<h3 class="text-lg font-semibold text-white">Edit Payload</h3>
<p class="text-sm text-zinc-400">Modify the payload for <span id="payloadToolName" class="font-mono text-blue-400"></span></p>
</div>
<button onclick="closePayloadModal()" class="text-zinc-400 hover:text-white">&times;</button>
</div>
<div class="border-b border-[#3f3f46] flex px-4 pt-2">
<button id="tabFormBtn" class="tab-btn active" onclick="switchPayloadTab('form')">Smart Form</button>
<button id="tabJsonBtn" class="tab-btn" onclick="switchPayloadTab('json')">Raw JSON</button>
</div>
<div class="modal-body">
<div id="payloadFormContainer" class="flex flex-col gap-3"></div>
<div id="payloadJsonContainer" style="display:none;">
<textarea id="payloadEditor" class="code-textarea"></textarea>
<p id="payloadError" class="text-red-500 text-xs mt-2 hidden">Invalid JSON format</p>
</div>
</div>
<div class="modal-footer">
<div class="flex-grow text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-zinc-500" fill="currentColor" viewBox="0 0 20 20"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 110 2 1 1 0 010-2z"></path></svg>
Auto-saved to LocalStorage
</div>
<button onclick="closePayloadModal()" class="btn-sm py-2 px-4">Cancel</button>
<button onclick="savePayload()" class="btn-execute py-2 px-4" style="font-size:12px;">Save & Apply</button>
</div>
</div>
</div>
<!-- Modal for Viewing Result -->
<div id="resultModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h3 class="text-lg font-semibold text-white">Execution Result</h3>
<button onclick="closeResultModal()" class="text-zinc-400 hover:text-white">&times;</button>
</div>
<div class="modal-body">
<p class="text-sm text-zinc-400 mb-2">Response for <span id="resultToolName" class="font-mono text-blue-400"></span></p>
<div id="resultViewer" class="code-viewer"></div>
</div>
<div class="modal-footer">
<div class="flex-grow flex gap-2">
<button class="btn-sm" onclick="copySnippet('curl')">Copy cURL</button>
<button class="btn-sm" onclick="copySnippet('java')">Copy Java</button>
</div>
<button onclick="closeResultModal()" class="btn-sm py-2 px-4">Close</button>
</div>
</div>
</div>
<script>
let allTools = [];
let filteredTools = [];
let isRunningAll = false;
let cancelRequested = false;
let currentEditIndex = -1;
let currentResultIndex = -1;
let chartInstance = null;
let currentPayloadMode = 'form'; // 'form' or 'json'
let pingIntervals = {};
function toggleAutoPing(index) {
const btn = document.getElementById(`ping-btn-${index}`);
if (pingIntervals[index]) {
clearInterval(pingIntervals[index]);
delete pingIntervals[index];
if(btn) {
btn.textContent = 'Ping Off';
btn.style.background = '';
btn.style.color = '';
btn.style.borderColor = '';
}
appendLog(`[Auto Ping] Stopped for ${allTools[index].name}.`, 'info');
} else {
appendLog(`[Auto Ping] Started for ${allTools[index].name} (Every 3 seconds).`, 'success');
executeCall(index);
pingIntervals[index] = setInterval(() => {
executeCall(index);
}, 3000);
if(btn) {
btn.textContent = 'Ping On';
btn.style.background = '#10b981';
btn.style.color = 'white';
btn.style.borderColor = '#10b981';
}
}
}
function appendLog(message, type = 'info') {
const viewer = document.getElementById('logViewer');
if (!viewer) return;
const timeStr = new Date().toLocaleTimeString();
let color = '#a1a1aa';
if (type === 'success') color = '#34d399';
if (type === 'error') color = '#f87171';
const logLine = `<div style="color: ${color};"><span class="text-zinc-500">[${timeStr}]</span> ${message}</div>`;
viewer.innerHTML += logLine;
viewer.scrollTop = viewer.scrollHeight;
}
function clearLogs() {
const viewer = document.getElementById('logViewer');
if (viewer) viewer.innerHTML = '<div style="color: #71717a;">[System] Console cleared.</div>';
}
async function fetchTools() {
try {
const response = await fetch('/mcp/api/v1/tools/list');
const data = await response.json();
allTools = data.result?.tools || [];
renderTable();
updateProgress(0);
// Load LocalStorage Payloads
const savedPayloads = JSON.parse(localStorage.getItem('mcp_tester_payloads') || '{}');
const categories = new Set();
allTools.forEach(t => {
t._customPayload = savedPayloads[t.name] || null;
t._lastStatus = 'NOT RUN';
t._isFailed = false;
t._latency = 0;
t._responseData = null;
categories.add(t.categoryKey || 'oth');
});
const catSelect = document.getElementById('categoryFilter');
Array.from(categories).sort().forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat.toUpperCase();
catSelect.appendChild(opt);
});
applyFilters();
} catch (error) {
document.getElementById('toolsTbody').innerHTML = `<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools. Gateway might be down.</td></tr>`;
document.getElementById('toolsTbody').innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools.</td></tr>';
}
}
function generateDummyPayload(schema) {
function persistCustomPayloads() {
const dict = {};
allTools.forEach(t => {
if (t._customPayload) dict[t.name] = t._customPayload;
});
localStorage.setItem('mcp_tester_payloads', JSON.stringify(dict));
}
function generateDummyPayload(tool) {
if (tool._customPayload) return tool._customPayload;
const schema = tool.parametersSchema;
if (!schema || !schema.properties) return {};
const payload = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (value.example !== undefined) {
payload[key] = value.example;
} else if (value.examples && Array.isArray(value.examples) && value.examples.length > 0) {
payload[key] = value.examples[0];
} else if (value.default !== undefined) {
payload[key] = value.default;
} else {
if (value.example !== undefined) payload[key] = value.example;
else if (value.examples && Array.isArray(value.examples) && value.examples.length > 0) payload[key] = value.examples[0];
else if (value.default !== undefined) payload[key] = value.default;
else {
if (value.type === 'string') {
if (value.enum && value.enum.length > 0) payload[key] = value.enum[0];
else payload[key] = "test_string";
} else if (value.type === 'integer' || value.type === 'number') {
payload[key] = value.minimum !== undefined ? value.minimum : 1;
} else if (value.type === 'boolean') {
payload[key] = true;
} else {
payload[key] = null;
}
} else if (value.type === 'integer' || value.type === 'number') payload[key] = value.minimum !== undefined ? value.minimum : 1;
else if (value.type === 'boolean') payload[key] = true;
else payload[key] = null;
}
}
return payload;
}
function applyFilters() {
const search = document.getElementById('searchInput').value.toLowerCase();
const category = document.getElementById('categoryFilter').value;
filteredTools = allTools.filter(t => {
const matchSearch = t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search);
const matchCategory = !category || (t.categoryKey || 'oth') === category;
return matchSearch && matchCategory;
});
document.getElementById('visibleCount').textContent = `(${filteredTools.length} tools)`;
renderTable();
updateProgress(0, filteredTools.length);
}
function renderTable() {
const tbody = document.getElementById('toolsTbody');
if (allTools.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools found.</td></tr>`;
if (filteredTools.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools match the filter.</td></tr>';
return;
}
allTools.sort((a, b) => (a.categoryKey || '').localeCompare(b.categoryKey || ''));
let html = '';
allTools.forEach((tool, index) => {
const payload = generateDummyPayload(tool.parametersSchema);
filteredTools.forEach((tool) => {
const index = allTools.indexOf(tool);
const payload = generateDummyPayload(tool);
const payloadStr = JSON.stringify(payload);
const shortPayload = payloadStr.length > 30 ? payloadStr.substring(0, 30) + '...' : payloadStr;
let statusHtml = '<span class="status-badge status-idle geist-mono">IDLE</span>';
if (tool._lastStatus !== 'NOT RUN') {
if (tool._lastStatus.startsWith("STRESS:")) {
statusHtml = `<span class="status-badge status-success geist-mono" style="border-color:#fbbf24; color:#fbbf24; background:rgba(251, 191, 36, 0.1);">${tool._lastStatus}</span>`;
} else if (tool._isFailed) {
statusHtml = `<span class="status-badge status-error geist-mono">${tool._lastStatus} &middot; ${tool._latency}ms</span>`;
} else {
statusHtml = `<span class="status-badge status-success geist-mono">${tool._lastStatus} &middot; ${tool._latency}ms</span>`;
}
}
html += `
<tr id="row-${index}">
<td><span class="cat-badge">${tool.categoryKey || 'oth'}</span></td>
<td class="font-medium text-slate-200">${tool.name}</td>
<td class="text-xs text-zinc-400 geist-mono" title='${payloadStr}'>${shortPayload}</td>
<td id="status-${index}"><span class="status-badge status-idle geist-mono">IDLE</span></td>
<td class="text-right">
<button class="btn-sm run-single-btn" onclick="runSingleTest(${index})">Test Single</button>
<td class="text-sm text-zinc-400 geist-mono flex items-center gap-3">
<span title='${payloadStr.replace(/'/g, "&apos;")}'>${shortPayload}</span>
<button class="btn-sm hover:text-white" style="padding:4px 8px; font-size:11px;" onclick="openPayloadModal(${index})">Edit</button>
</td>
<td id="status-${index}">${statusHtml}</td>
<td class="text-right flex items-center justify-end gap-3">
<button class="btn-sm run-single-btn" onclick="runSingleTest(${index})">Test</button>
<button class="btn-stress run-stress-btn" onclick="runStressTest(${index})">Stress</button>
<button id="ping-btn-${index}" class="btn-sm" style="${pingIntervals[index] ? 'background:#10b981; color:white; border-color:#10b981;' : ''}" onclick="toggleAutoPing(${index})">${pingIntervals[index] ? 'Ping On' : 'Ping Off'}</button>
<button class="btn-sm" style="${tool._responseData ? '' : 'display:none;'}" id="view-res-${index}" onclick="openResultModal(${index})">Result</button>
</td>
</tr>
`;
@@ -215,17 +397,48 @@
tbody.innerHTML = html;
}
function updateProgress(completed) {
document.getElementById('progressInfo').textContent = `Total: ${allTools.length} / Completed: ${completed}`;
function updateProgress(completed, total) {
document.getElementById('progressInfo').textContent = `Total: ${total} / Completed: ${completed}`;
if (total > 0) {
document.getElementById('progressBarFill').style.width = `${(completed / total) * 100}%`;
} else {
document.getElementById('progressBarFill').style.width = '0%';
}
}
async function executeCall(toolName, payload, index) {
async function executeCall(index) {
const tool = allTools[index];
const payload = generateDummyPayload(tool);
const statusCell = document.getElementById(`status-${index}`);
statusCell.innerHTML = `<span class="status-badge status-running geist-mono">RUNNING...</span>`;
if(statusCell) statusCell.innerHTML = '<span class="status-badge status-running geist-mono">RUNNING...</span>';
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: toolName, arguments: payload }, id: Date.now() };
appendLog(`Executing ${tool.name}...`, 'info');
const res = await executeRawCall(tool.name, payload);
tool._latency = res.latency;
tool._isFailed = !res.success;
tool._lastStatus = res.status;
tool._responseData = res.data;
if (tool._isFailed) {
appendLog(`❌ [ERROR] ${tool.name} failed (${res.status}) - ${res.latency}ms`, 'error');
} else {
appendLog(`✅ [SUCCESS] ${tool.name} completed - ${res.latency}ms`, 'success');
}
// Update UI if visible
const btnRes = document.getElementById(`view-res-${index}`);
if(btnRes) btnRes.style.display = 'inline-block';
if(statusCell) {
if (tool._isFailed) statusCell.innerHTML = `<span class="status-badge status-error geist-mono">${tool._lastStatus} &middot; ${tool._latency}ms</span>`;
else statusCell.innerHTML = `<span class="status-badge status-success geist-mono">${tool._lastStatus} &middot; ${tool._latency}ms</span>`;
}
}
async function executeRawCall(name, payload) {
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: name, arguments: payload }, id: Date.now() };
const startTime = Date.now();
try {
const response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST',
@@ -233,104 +446,399 @@
body: JSON.stringify(reqPayload)
});
const latency = Date.now() - startTime;
allTools[index]._latency = latency;
if (response.ok) {
// API layer might return 200 OK but contain an error message in MCP format.
const data = await response.json();
let respData = data;
if (data?.result?.result) {
respData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
} else if (data?.error) {
respData = data.error;
}
allTools[index]._responseData = respData;
if (data?.result?.result) respData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
else if (data?.error) respData = data.error;
if (data.result && data.result.isError) {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">ERR: MCP ERROR &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = "ERR: MCP ERROR";
if (data.result && data.result.isError) return { success: false, latency, status: "ERR: MCP ERROR", data: respData };
return { success: true, latency, status: "200 OK", data: respData };
} else {
statusCell.innerHTML = `<span class="status-badge status-success geist-mono">200 OK &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = "200 OK";
}
} else {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">HTTP ${response.status} &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = `HTTP ${response.status}`;
return { success: false, latency, status: `HTTP ${response.status}`, data: await response.text() };
}
} catch (error) {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">NET ERR</span>`;
allTools[index]._lastStatus = "NET ERR";
allTools[index]._latency = 0;
return { success: false, latency: 0, status: "NET ERR", data: error.toString() };
}
}
async function runSingleTest(index) {
const tool = allTools[index];
const payload = generateDummyPayload(tool.parametersSchema);
const btn = document.querySelector(`#row-${index} .run-single-btn`);
btn.disabled = true;
await executeCall(tool.name, payload, index);
btn.disabled = false;
if(btn) btn.disabled = true;
await executeCall(index);
if(btn) btn.disabled = false;
}
async function runAllTests() {
async function runStressTest(index) {
const reqCount = 50;
const tool = allTools[index];
const payload = generateDummyPayload(tool);
const statusCell = document.getElementById(`status-${index}`);
const btn1 = document.querySelector(`#row-${index} .run-single-btn`);
const btn2 = document.querySelector(`#row-${index} .run-stress-btn`);
if(btn1) btn1.disabled = true;
if(btn2) btn2.disabled = true;
if(statusCell) statusCell.innerHTML = '<span class="status-badge status-running geist-mono" style="border-color:#fbbf24; color:#fbbf24; background:rgba(251, 191, 36, 0.1);">STRESS TESTING...</span>';
const promises = [];
for(let i = 0; i < reqCount; i++) {
promises.push(executeRawCall(tool.name, payload));
}
const results = await Promise.all(promises);
const latencies = results.map(r => r.latency);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / reqCount;
const successes = results.filter(r => r.success).length;
tool._lastStatus = `STRESS: ${successes}/${reqCount} OK`;
tool._latency = Math.round(avg);
tool._isFailed = (successes < reqCount);
tool._responseData = `[Stress Test Report - ${reqCount} Concurrent Requests]\n\nSuccess: ${successes}\nFailed: ${reqCount - successes}\n\nAvg Latency: ${avg.toFixed(2)} ms\nMax Latency: ${max} ms\nMin Latency: ${min} ms\n`;
if(statusCell) {
statusCell.innerHTML = `<span class="status-badge status-success geist-mono" style="border-color:#fbbf24; color:#fbbf24; background:rgba(251, 191, 36, 0.1);">${tool._lastStatus} &middot; Avg ${Math.round(avg)}ms</span>`;
}
const btnRes = document.getElementById(`view-res-${index}`);
if(btnRes) btnRes.style.display = 'inline-block';
if(btn1) btn1.disabled = false;
if(btn2) btn2.disabled = false;
}
function cancelTests() {
cancelRequested = true;
document.getElementById('cancelBtn').innerHTML = 'Canceling...';
}
async function runAllTests(onlyFailed = false) {
if (isRunningAll) return;
isRunningAll = true;
cancelRequested = false;
const btn = document.getElementById('runAllBtn');
btn.disabled = true;
btn.innerHTML = `<div class="spinner border-[2px]" style="width:16px;height:16px;border-top-color:white;margin-right:8px;"></div> Running...`;
document.getElementById('chartContainer').style.display = 'none';
// Reset statuses
for (let i = 0; i < allTools.length; i++) {
document.getElementById(`status-${i}`).innerHTML = `<span class="status-badge status-idle geist-mono">IDLE</span>`;
}
const btnRun = document.getElementById('runAllBtn');
const btnCancel = document.getElementById('cancelBtn');
const btnFailed = document.getElementById('runFailedBtn');
btnRun.style.display = 'none';
btnFailed.style.display = 'none';
btnCancel.style.display = 'flex';
btnCancel.innerHTML = 'Stop Tests';
document.getElementById('progressBarBg').style.display = 'block';
const targetTools = onlyFailed ? filteredTools.filter(t => t._isFailed) : filteredTools;
let completed = 0;
// Execute in batches to avoid overwhelming the gateway/services
updateProgress(0, targetTools.length);
targetTools.forEach(t => {
const idx = allTools.indexOf(t);
t._lastStatus = 'NOT RUN';
t._isFailed = false;
t._responseData = null;
const cell = document.getElementById(`status-${idx}`);
if(cell) cell.innerHTML = '<span class="status-badge status-idle geist-mono">IDLE</span>';
const resBtn = document.getElementById(`view-res-${idx}`);
if(resBtn) resBtn.style.display = 'none';
});
const batchSize = 3;
for (let i = 0; i < allTools.length; i += batchSize) {
const batch = allTools.slice(i, i + batchSize);
const promises = batch.map((tool, idx) => {
const actualIndex = i + idx;
const payload = generateDummyPayload(tool.parametersSchema);
return executeCall(tool.name, payload, actualIndex).then(() => {
for (let i = 0; i < targetTools.length; i += batchSize) {
if (cancelRequested) break;
const batch = targetTools.slice(i, i + batchSize);
const promises = batch.map((tool) => {
const idx = allTools.indexOf(tool);
return executeCall(idx).then(() => {
completed++;
updateProgress(completed);
updateProgress(completed, targetTools.length);
});
});
await Promise.all(promises);
}
isRunningAll = false;
btn.disabled = false;
btn.innerHTML = `<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Run All Tests`;
btnCancel.style.display = 'none';
btnRun.style.display = 'flex';
// Show Export CSV button after all tests run
document.getElementById('exportCsvBtn').style.display = 'flex';
if (filteredTools.some(t => t._isFailed)) {
btnFailed.style.display = 'flex';
}
renderChart(targetTools);
}
function renderChart(testedTools) {
const dataTools = testedTools.filter(t => t._lastStatus !== 'NOT RUN');
if (dataTools.length === 0) return;
document.getElementById('chartContainer').style.display = 'block';
const ctx = document.getElementById('latencyChart').getContext('2d');
const gradientSuccess = ctx.createLinearGradient(0, 0, 0, 400);
gradientSuccess.addColorStop(0, 'rgba(56, 189, 248, 0.9)'); // sky-400
gradientSuccess.addColorStop(1, 'rgba(59, 130, 246, 0.1)'); // blue-500
const gradientError = ctx.createLinearGradient(0, 0, 0, 400);
gradientError.addColorStop(0, 'rgba(248, 113, 113, 0.9)'); // red-400
gradientError.addColorStop(1, 'rgba(220, 38, 38, 0.1)'); // red-600
const fullLabels = dataTools.map(t => t.name);
const labels = dataTools.map(t => {
const parts = t.name.split('.');
let short = parts.length > 1 ? parts.slice(-2).join('.') : t.name;
return short.length > 18 ? short.substring(0, 16) + '..' : short;
});
const latencies = dataTools.map(t => t._latency);
const colors = dataTools.map(t => t._isFailed ? gradientError : gradientSuccess);
const borderColors = dataTools.map(t => t._isFailed ? 'rgba(248, 113, 113, 1)' : 'rgba(56, 189, 248, 1)');
if (chartInstance) chartInstance.destroy();
chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Latency (ms)',
data: latencies,
backgroundColor: colors,
borderColor: borderColors,
borderWidth: 1,
borderRadius: 6,
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1200,
easing: 'easeOutQuart'
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(9, 9, 11, 0.9)',
titleFont: { family: 'Geist', size: 14, weight: '600' },
bodyFont: { family: 'Geist Mono', size: 13 },
padding: 12,
cornerRadius: 8,
borderColor: 'rgba(63, 63, 70, 0.5)',
borderWidth: 1,
displayColors: false,
callbacks: {
title: function(context) {
return fullLabels[context[0].dataIndex];
},
label: function(context) {
return context.parsed.y + ' ms';
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.04)', drawBorder: false, tickLength: 0 },
ticks: { color: '#71717a', font: { family: 'Geist Mono', size: 11 }, padding: 10 }
},
x: {
grid: { display: false, drawBorder: false },
ticks: { color: '#a1a1aa', font: { family: 'Geist', size: 12 }, maxRotation: 45, minRotation: 45, padding: 10 }
}
}
}
});
}
/* Edit Payload Modal & Smart Form */
function switchPayloadTab(mode) {
currentPayloadMode = mode;
document.getElementById('tabFormBtn').classList.toggle('active', mode === 'form');
document.getElementById('tabJsonBtn').classList.toggle('active', mode === 'json');
document.getElementById('payloadFormContainer').style.display = mode === 'form' ? 'flex' : 'none';
document.getElementById('payloadJsonContainer').style.display = mode === 'json' ? 'block' : 'none';
if (mode === 'form') {
// If switching back to form, maybe try to parse the JSON and populate the form
try {
const parsed = JSON.parse(document.getElementById('payloadEditor').value);
const tool = allTools[currentEditIndex];
tool._tempParsed = parsed;
renderSmartForm(tool, parsed);
} catch(e) {}
} else {
// If switching to JSON, serialize the current form to textarea
const formObj = serializeSmartForm();
document.getElementById('payloadEditor').value = JSON.stringify(formObj, null, 2);
}
}
function renderSmartForm(tool, payloadToUse = null) {
const schema = tool.parametersSchema;
const form = document.getElementById('payloadFormContainer');
form.innerHTML = '';
if(!schema || !schema.properties || Object.keys(schema.properties).length === 0) {
form.innerHTML = '<div class="text-sm text-zinc-500 py-4 text-center border border-[#3f3f46] rounded bg-[#18181b]">This tool requires no parameters.</div>';
return;
}
const currentPayload = payloadToUse || generateDummyPayload(tool);
let html = '';
for (const [key, value] of Object.entries(schema.properties)) {
let val = currentPayload[key];
if(val === undefined) val = '';
let inputHtml = '';
let typeLabel = value.type || 'string';
if(value.enum && value.enum.length > 0) {
let options = value.enum.map(e => `<option value="${e}" ${val===e?'selected':''}>${e}</option>`).join('');
inputHtml = `<select data-key="${key}" data-type="enum" class="bg-[#09090b] border border-[#3f3f46] text-white text-sm rounded p-2 w-full focus:border-blue-500 outline-none">${options}</select>`;
typeLabel = 'enum';
} else if(value.type === 'boolean') {
inputHtml = `<input type="checkbox" data-key="${key}" data-type="boolean" class="w-4 h-4 text-blue-600 bg-[#09090b] border-[#3f3f46] rounded focus:ring-blue-500" ${val?'checked':''}>`;
} else if(value.type === 'integer' || value.type === 'number') {
inputHtml = `<input type="number" data-key="${key}" data-type="number" value="${val}" class="bg-[#09090b] border border-[#3f3f46] text-white text-sm rounded p-2 w-full focus:border-blue-500 outline-none">`;
} else if (value.type === 'object' || value.type === 'array') {
// For complex types, use a small text area
let stringVal = typeof val === 'object' ? JSON.stringify(val) : val;
inputHtml = `<textarea data-key="${key}" data-type="object" class="bg-[#09090b] border border-[#3f3f46] text-white text-sm rounded p-2 w-full focus:border-blue-500 outline-none h-20">${stringVal}</textarea>`;
} else {
inputHtml = `<input type="text" data-key="${key}" data-type="string" value="${typeof val === 'object' ? JSON.stringify(val).replace(/"/g, '&quot;') : val}" class="bg-[#09090b] border border-[#3f3f46] text-white text-sm rounded p-2 w-full focus:border-blue-500 outline-none">`;
}
let reqStar = (schema.required && schema.required.includes(key)) ? '<span class="text-red-500">*</span>' : '';
html += `<div class="bg-[#18181b] p-4 rounded-lg border border-[#27272a]">
<label class="block text-sm font-medium text-slate-300 mb-2">${key} ${reqStar} <span class="text-xs text-zinc-500 font-mono font-normal ml-2">${typeLabel}</span></label>
${inputHtml}
${value.description ? `<p class="text-sm text-zinc-500 mt-2">${value.description}</p>` : ''}
</div>`;
}
form.innerHTML = html;
}
function serializeSmartForm() {
const inputs = document.querySelectorAll('#payloadFormContainer [data-key]');
const obj = {};
inputs.forEach(el => {
const key = el.getAttribute('data-key');
const type = el.getAttribute('data-type');
if(type === 'boolean') {
obj[key] = el.checked;
} else if(type === 'number') {
obj[key] = Number(el.value);
} else if(type === 'object') {
try { obj[key] = JSON.parse(el.value); } catch(e) { obj[key] = el.value; }
} else if(type === 'string' && (el.value.startsWith('{') || el.value.startsWith('['))) {
try { obj[key] = JSON.parse(el.value); } catch(e) { obj[key] = el.value; }
} else {
obj[key] = el.value;
}
});
return obj;
}
function openPayloadModal(index) {
currentEditIndex = index;
const tool = allTools[index];
document.getElementById('payloadToolName').textContent = tool.name;
const payload = generateDummyPayload(tool);
// Initialize both modes
document.getElementById('payloadEditor').value = JSON.stringify(payload, null, 2);
renderSmartForm(tool, payload);
switchPayloadTab('form'); // Default to form mode
document.getElementById('payloadError').classList.add('hidden');
document.getElementById('payloadModal').style.display = 'flex';
}
function closePayloadModal() {
document.getElementById('payloadModal').style.display = 'none';
}
function savePayload() {
let finalPayload = {};
if (currentPayloadMode === 'form') {
finalPayload = serializeSmartForm();
} else {
const raw = document.getElementById('payloadEditor').value;
try {
finalPayload = JSON.parse(raw);
} catch (e) {
document.getElementById('payloadError').classList.remove('hidden');
return;
}
}
allTools[currentEditIndex]._customPayload = finalPayload;
persistCustomPayloads(); // Save to LocalStorage
closePayloadModal();
renderTable();
}
/* Result Modal & Code Snippets */
function openResultModal(index) {
currentResultIndex = index;
const tool = allTools[index];
document.getElementById('resultToolName').textContent = tool.name;
let resTxt = typeof tool._responseData === 'object' ? JSON.stringify(tool._responseData, null, 2) : tool._responseData;
document.getElementById('resultViewer').textContent = resTxt;
document.getElementById('resultModal').style.display = 'flex';
}
function closeResultModal() {
document.getElementById('resultModal').style.display = 'none';
}
function copySnippet(type) {
if(currentResultIndex === -1) return;
const tool = allTools[currentResultIndex];
const payload = generateDummyPayload(tool);
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() };
let text = '';
if(type === 'curl') {
text = `curl -X POST http://localhost:8081/mcp/api/v1/tools/call \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify(reqPayload)}'`;
} else if (type === 'java') {
const jsonStr = JSON.stringify(reqPayload).replace(/"/g, '\\"');
text = `// Java RestTemplate or WebClient Payload\nString payload = "${jsonStr}";\n\n// Example using RestTemplate:\n// HttpHeaders headers = new HttpHeaders();\n// headers.setContentType(MediaType.APPLICATION_JSON);\n// HttpEntity<String> request = new HttpEntity<>(payload, headers);\n// String response = restTemplate.postForObject("http://localhost:8081/mcp/api/v1/tools/call", request, String.class);`;
}
navigator.clipboard.writeText(text).then(() => {
alert(`[${type.toUpperCase()}] code snippet copied to clipboard!`);
});
}
function exportCsv() {
if (allTools.length === 0) return;
if (filteredTools.length === 0) return;
let csvContent = "Category,Tool Name,Status,Latency(ms),Payload,Response\n";
allTools.forEach(tool => {
filteredTools.forEach(tool => {
const category = `"${(tool.categoryKey || 'oth').replace(/"/g, '""')}"`;
const name = `"${(tool.name || '').replace(/"/g, '""')}"`;
const status = `"${(tool._lastStatus || 'NOT RUN').replace(/"/g, '""')}"`;
const latency = tool._latency || 0;
const payload = generateDummyPayload(tool.parametersSchema);
const payload = generateDummyPayload(tool);
const payloadStr = `"${JSON.stringify(payload).replace(/"/g, '""')}"`;
const resp = tool._responseData ? JSON.stringify(tool._responseData) : "";
const resp = tool._responseData ? (typeof tool._responseData === 'object' ? JSON.stringify(tool._responseData) : tool._responseData) : "";
const responseStr = `"${resp.replace(/"/g, '""')}"`;
csvContent += `${category},${name},${status},${latency},${payloadStr},${responseStr}\n`;
});
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");