feat(ui): add tool-test-console and tester dashboards
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m51s

- Add Auto-Tester Dashboard (tester.html) in gateway for batch testing tools

- Add standalone Tool Test Console (tool-test-console.html) in core

- Fix Tailwind CSS Preflight conflicts in console UI

- Update console payload schema resolution to support both gateway and pod modes

- Strip JSON-RPC metadata from tool payload output in console

- Add unified navigation headers across all static HTML pages
This commit is contained in:
jade
2026-08-04 18:24:52 +09:00
parent 39c1f4ee12
commit 1a70495686
9 changed files with 660 additions and 4 deletions

View File

@@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<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>
<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: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-sm:hover { background: #3f3f46; color: white; }
.btn-sm: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 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-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;
}
</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">
<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>
</a>
<div class="h-4 w-px" style="background:#27272a;"></div>
<nav class="flex space-x-5 text-[13px] 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>
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
</div>
</div>
</header>
<main class="max-w-5xl mx-auto px-6 py-10">
<div class="flex items-center justify-between mb-8">
<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>
</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>
Run All Tests
</button>
</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>
<div class="overflow-x-auto">
<table class="table" id="toolsTable">
<thead>
<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>
</tr>
</thead>
<tbody id="toolsTbody">
<tr>
<td colspan="5" class="text-center text-zinc-500 py-8">Loading tools...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<script>
let allTools = [];
let isRunningAll = false;
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);
} 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>`;
}
}
function generateDummyPayload(schema) {
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.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;
}
}
}
return payload;
}
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>`;
return;
}
allTools.sort((a, b) => (a.categoryKey || '').localeCompare(b.categoryKey || ''));
let html = '';
allTools.forEach((tool, index) => {
const payload = generateDummyPayload(tool.parametersSchema);
const payloadStr = JSON.stringify(payload);
const shortPayload = payloadStr.length > 30 ? payloadStr.substring(0, 30) + '...' : payloadStr;
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>
</tr>
`;
});
tbody.innerHTML = html;
}
function updateProgress(completed) {
document.getElementById('progressInfo').textContent = `Total: ${allTools.length} / Completed: ${completed}`;
}
async function executeCall(toolName, payload, index) {
const statusCell = document.getElementById(`status-${index}`);
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() };
const startTime = Date.now();
try {
const response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Agent-Id': 'AUTO-TESTER' },
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 && 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";
} 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}`;
}
} catch (error) {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">NET ERR</span>`;
allTools[index]._lastStatus = "NET ERR";
allTools[index]._latency = 0;
}
}
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;
}
async function runAllTests() {
if (isRunningAll) return;
isRunningAll = true;
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>`;
}
let completed = 0;
// Execute in batches to avoid overwhelming the gateway/services
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(() => {
completed++;
updateProgress(completed);
});
});
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`;
// Show Export CSV button after all tests run
document.getElementById('exportCsvBtn').style.display = 'flex';
}
function exportCsv() {
if (allTools.length === 0) return;
let csvContent = "Category,Tool Name,Status,Latency(ms),Payload,Response\n";
allTools.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 payloadStr = `"${JSON.stringify(payload).replace(/"/g, '""')}"`;
const resp = tool._responseData ? JSON.stringify(tool._responseData) : "";
const responseStr = `"${resp.replace(/"/g, '""')}"`;
csvContent += `${category},${name},${status},${latency},${payloadStr},${responseStr}\n`;
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `mcp_tools_test_report_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
document.addEventListener('DOMContentLoaded', fetchTools);
</script>
</body>
</html>