feat: MCP Tool Tester UI/UX 고도화 (Stress 테스트, 실시간 로그 콘솔, Auto-Ping 등 추가)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s
This commit is contained in:
@@ -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">×</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">×</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} · ${tool._latency}ms</span>`;
|
||||
} else {
|
||||
statusHtml = `<span class="status-badge status-success geist-mono">${tool._lastStatus} · ${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, "'")}'>${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} · ${tool._latency}ms</span>`;
|
||||
else statusCell.innerHTML = `<span class="status-badge status-success geist-mono">${tool._lastStatus} · ${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 · ${latency}ms</span>`;
|
||||
allTools[index]._lastStatus = "ERR: MCP ERROR";
|
||||
} else {
|
||||
statusCell.innerHTML = `<span class="status-badge status-success geist-mono">200 OK · ${latency}ms</span>`;
|
||||
allTools[index]._lastStatus = "200 OK";
|
||||
}
|
||||
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-error geist-mono">HTTP ${response.status} · ${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 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} · 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;
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
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...`;
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
|
||||
document.getElementById('chartContainer').style.display = 'none';
|
||||
|
||||
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, '"') : 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");
|
||||
|
||||
Reference in New Issue
Block a user