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

@@ -12,6 +12,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -21,6 +22,7 @@ import org.springframework.stereotype.Service;
public class ToolManifestService {
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
private static final AtomicLong LAST_ISSUED_REVISION = new AtomicLong();
private final Supplier<List<ToolMetadata>> toolSupplier;
private final ObjectMapper objectMapper;
private final McpProperties properties;
@@ -89,10 +91,9 @@ public class ToolManifestService {
private synchronized String revision(String bundleId, List<ToolManifestItem> tools) {
String fingerprint = fingerprint(bundleId, tools);
if (!fingerprint.equals(lastFingerprint)) {
long nextTimestamp = System.currentTimeMillis();
if (lastRevision != null) {
nextTimestamp = Math.max(nextTimestamp, Long.parseLong(lastRevision) + 1);
}
long localMinimum = lastRevision == null ? Long.MIN_VALUE : Long.parseLong(lastRevision) + 1;
long nextTimestamp = LAST_ISSUED_REVISION.updateAndGet(previous ->
Math.max(Math.max(System.currentTimeMillis(), localMinimum), previous + 1));
lastFingerprint = fingerprint;
lastRevision = Long.toString(nextTimestamp);
}

View File

@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AX HUB Tool Test Console</title>
<style>
:root { color-scheme: dark; --bg:#09090b; --surface:#18181b; --surface2:#27272a; --line:#3f3f46; --muted:#a1a1aa; --text:#f4f4f5; --blue:#60a5fa; --green:#34d399; --red:#fb7185; --yellow:#fbbf24; }
* { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font-family:Arial,"Malgun Gothic",sans-serif; }
header { border-bottom:1px solid var(--line); background:#111113; position:sticky; top:0; z-index:2; } .header-inner { max-width:1280px; min-height:64px; margin:auto; padding:0 24px; display:flex; align-items:center; justify-content:space-between; gap:16px; }
h1 { margin:0; font-size:18px; } h2 { font-size:15px; margin:0 0 14px; } p { color:var(--muted); line-height:1.55; }
main { max-width:1280px; margin:auto; padding:28px 24px 48px; } .intro { margin-bottom:20px; } .intro h1 { font-size:24px; margin-bottom:8px; }
.grid { display:grid; grid-template-columns:340px minmax(0,1fr); gap:18px; } .card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px; }
label { display:block; color:var(--muted); font-size:12px; font-weight:700; margin:0 0 7px; } input, select, textarea { width:100%; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#101012; color:var(--text); font:14px ui-monospace,SFMono-Regular,Consolas,monospace; } textarea { min-height:270px; resize:vertical; line-height:1.55; }
select { font-family:Arial,"Malgun Gothic",sans-serif; } .stack { display:grid; gap:12px; } .buttons { display:flex; gap:8px; flex-wrap:wrap; }
button { border:1px solid var(--line); border-radius:8px; padding:9px 12px; color:var(--text); background:var(--surface2); cursor:pointer; font-weight:700; } button:hover { border-color:var(--blue); } button.primary { background:#2563eb; border-color:#3b82f6; } button.danger { color:#fecdd3; } button:disabled { opacity:.5; cursor:not-allowed; }
.meta { display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px; } .badge { border:1px solid var(--line); border-radius:99px; padding:4px 8px; color:var(--muted); font-size:11px; font-family:ui-monospace,monospace; } .badge.ok { color:var(--green); border-color:#166534; } .badge.fail { color:var(--red); border-color:#9f1239; }
.notice { border-left:3px solid var(--blue); padding:10px 12px; background:#172554; color:#dbeafe; border-radius:4px; font-size:13px; margin-bottom:16px; } .hidden { display:none !important; }
.result { background:#101012; border:1px solid var(--line); border-radius:8px; padding:14px; white-space:pre-wrap; overflow:auto; max-height:520px; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
.case-list { display:grid; gap:8px; margin-top:12px; } .case-row { display:flex; align-items:center; gap:8px; padding:9px; border:1px solid var(--line); border-radius:8px; } .case-row main { padding:0; margin:0; flex:1; min-width:0; } .case-row strong,.case-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .case-row small { color:var(--muted); margin-top:3px; }
.run-log { margin-top:12px; font:12px/1.55 ui-monospace,monospace; color:var(--muted); white-space:pre-wrap; max-height:220px; overflow:auto; }
.footer-note { margin-top:18px; color:#71717a; font-size:12px; } @media (max-width:900px) { .grid { grid-template-columns:1fr; } .header-inner { padding:0 16px; } main { padding:20px 16px; } }
</style>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: {
preflight: false,
}
}
</script>
</head>
<body class="min-h-screen">
<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" style="text-decoration:none;">
<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; text-decoration:none;" class="hover:text-white transition-colors">Scaffold</a>
<a href="/catalog.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Playground</a>
<a href="/chat.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#ffffff; text-decoration:none;" class="font-semibold">Console</a>
</nav>
</div>
<div class="flex items-center gap-3">
<span class="badge" id="manifestStatus" style="border:1px solid #3f3f46; border-radius:99px; padding:4px 8px; color:#a1a1aa; font-size:11px; font-family:ui-monospace,monospace;">Manifest loading</span>
<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>
<section class="intro"><h1>Schema 기반 Tool 테스트</h1><p>Tool을 선택하고 요청 JSON을 확인한 뒤 실행하세요. 검증한 요청은 브라우저에 저장되며, 저장된 케이스 전체를 한 번에 다시 실행할 수 있습니다.</p></section>
<div class="grid">
<aside class="stack">
<section class="card stack"><h2>1. Tool 선택</h2><div><label for="filter">검색</label><input id="filter" placeholder="이름, 설명으로 검색"></div><div><label for="toolSelect">Tool</label><select id="toolSelect" size="12"></select></div><div class="meta" id="toolMeta"></div><div class="buttons"><button id="sampleButton">Schema 샘플 채우기</button><button id="reloadButton">Manifest 새로고침</button></div></section>
<section class="card"><h2>저장된 테스트 케이스</h2><div class="buttons"><button class="primary" id="runAllButton">Run saved cases</button><button class="danger" id="clearCasesButton">전체 삭제</button></div><div id="caseList" class="case-list"></div><div id="runLog" class="run-log"></div></section>
</aside>
<section class="stack">
<section class="card"><h2>2. 요청 JSON</h2><div class="notice">필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.</div><textarea id="arguments" spellcheck="false" aria-label="요청 JSON"></textarea><div class="buttons" style="margin-top:12px"><button id="saveButton">현재 요청 저장</button><button class="primary" id="executeButton">실행</button></div></section>
<section class="card"><h2>3. 실행 결과</h2><div class="meta"><span class="badge" id="httpStatus">대기</span><span class="badge" id="latency">-</span><span class="badge" id="traceId">trace-id: -</span><span class="badge" id="requestId">request-id: -</span></div><pre class="result" id="result">Tool을 선택하고 실행하세요.</pre></section>
</section>
</div>
<p class="footer-note">이 화면은 현재 Tool Pod의 <code>/tool-manifest</code><code>/mcp/{toolName}</code>만 사용합니다. 저장된 케이스는 이 브라우저의 localStorage에만 보관됩니다.</p>
</main>
<script>
(() => {
const STORAGE_KEY = 'axhub.tool-test-console.cases.v1';
const state = { tools: [], selected: null, cases: loadCases() };
const $ = id => document.getElementById(id);
const select = $('toolSelect'), args = $('arguments'), result = $('result');
function loadCases() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch (_) { return []; } }
function persistCases() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.cases)); renderCases(); }
function requestId() { return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; }
function escapeHtml(value) { return String(value).replace(/[&<>]/g, item => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[item])); }
let isGatewayMode = false;
async function loadManifest() {
$('manifestStatus').textContent = 'Manifest loading';
try {
let response = await fetch('/tool-manifest', { headers: { 'Cache-Control': 'no-cache' } });
if (!response.ok && response.status === 404) {
// Gateway 환경 감지 및 Fallback 처리
response = await fetch('/mcp/api/v1/tools/list');
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
const rpcData = await response.json();
state.tools = rpcData.result?.tools || [];
isGatewayMode = true;
$('manifestStatus').textContent = `${state.tools.length} tools · Gateway Mode`;
$('manifestStatus').className = 'badge ok';
renderTools();
return;
}
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`);
const manifest = await response.json();
state.tools = (manifest.tools || []).filter(tool => tool._meta?.enabled !== false);
$('manifestStatus').textContent = `${state.tools.length} tools · rev ${manifest.revision || '-'}`;
$('manifestStatus').className = 'badge ok';
renderTools();
} catch (error) {
$('manifestStatus').textContent = `Load error: ${error.message}`;
$('manifestStatus').className = 'badge fail';
select.innerHTML = '<option>Tool 목록을 불러오지 못했습니다</option>';
}
}
function renderTools() {
const query = $('filter').value.trim().toLowerCase();
const tools = state.tools.filter(tool => `${tool.name} ${tool.title || ''} ${tool.description || ''}`.toLowerCase().includes(query));
select.innerHTML = '';
for (const tool of tools) {
const option = document.createElement('option'); option.value = tool.name;
option.textContent = `${tool.name}${tool.title || tool.description || ''}`; select.appendChild(option);
}
if (tools.length) choose(tools.find(tool => tool.name === state.selected?.name) || tools[0]);
else { state.selected = null; $('toolMeta').innerHTML = ''; args.value = ''; }
}
function choose(tool) {
state.selected = tool; select.value = tool.name;
const meta = tool._meta || {}; const hints = tool.annotations || {};
$('toolMeta').innerHTML = [
`<span class="badge">${escapeHtml(tool.name)}</span>`,
`<span class="badge">timeout ${meta.timeoutMillis || 300000}ms</span>`,
hints.readOnlyHint ? '<span class="badge ok">read-only</span>' : '',
hints.destructiveHint ? '<span class="badge fail">destructive</span>' : ''
].join('');
args.value = JSON.stringify(exampleForSchema(tool.inputSchema || tool.parametersSchema || {}), null, 2);
result.textContent = tool.description || '설명이 없습니다.';
resetResult();
}
function exampleForSchema(schema) {
if (schema.default !== undefined) return schema.default;
if (Array.isArray(schema.examples) && schema.examples.length) return schema.examples[0];
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
if (schema.type === 'object' || schema.properties) {
const output = {}; const required = new Set(schema.required || []);
for (const [name, property] of Object.entries(schema.properties || {})) {
if (required.has(name) || property.default !== undefined || property.examples?.length || property.enum?.length) output[name] = exampleForSchema(property);
}
return output;
}
if (schema.type === 'array') return [];
if (schema.type === 'integer') return 1;
if (schema.type === 'number') return 1.0;
if (schema.type === 'boolean') return false;
if (schema.format === 'date') return new Date().toISOString().slice(0, 10);
if (schema.format === 'date-time') return new Date().toISOString();
return 'test-value';
}
function parseArguments() { try { const value = JSON.parse(args.value || '{}'); if (value && typeof value === 'object' && !Array.isArray(value)) return value; throw new Error('요청 JSON은 객체여야 합니다.'); } catch (error) { throw new Error(`요청 JSON 오류: ${error.message}`); } }
function endpointFor(tool) {
const fallback = `/mcp/${encodeURIComponent(tool.name)}`;
try { const endpoint = new URL(tool.endpoint || fallback, window.location.origin); return endpoint.origin === window.location.origin ? `${endpoint.pathname}${endpoint.search}` : fallback; } catch (_) { return fallback; }
}
function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'trace-id: -'; $('requestId').textContent = 'request-id: -'; }
async function execute(tool = state.selected, body = null) {
if (!tool) throw new Error('실행할 Tool을 선택하세요.');
const payload = body || parseArguments(); const trace = requestId(), request = requestId(), started = performance.now();
$('executeButton').disabled = true; $('httpStatus').textContent = '실행 중'; $('httpStatus').className = 'badge';
try {
let response;
if (isGatewayMode) {
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() };
response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST',
headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request },
body: JSON.stringify(reqPayload)
});
} else {
response = await fetch(endpointFor(tool), {
method: 'POST',
headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request },
body: JSON.stringify(payload)
});
}
const text = await response.text(); let data; try { data = text ? JSON.parse(text) : null; } catch (_) { data = text; }
let isOk = response.ok;
let displayStatus = response.status;
if (isGatewayMode && isOk && data?.result?.isError) {
isOk = false;
displayStatus = "MCP ERR";
}
const elapsed = Math.round(performance.now() - started);
$('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`;
$('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `trace-id: ${response.headers.get('trace-id') || trace}`; $('requestId').textContent = `request-id: ${response.headers.get('request-id') || request}`;
let displayData = data;
if (isGatewayMode && data && typeof data === 'object') {
if (data.result && data.result.result) {
displayData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
} else if (data.error) {
displayData = data.error;
} else if (data.result && data.result.error_message) {
displayData = { error: data.result.error_message, code: data.result.error_code };
}
}
result.textContent = JSON.stringify(displayData, null, 2); return { ok: isOk, status: displayStatus, elapsed, data };
} finally { $('executeButton').disabled = false; }
}
function saveCase() {
try {
const payload = parseArguments(); const name = window.prompt('테스트 케이스 이름', state.selected.name);
if (!name) return;
state.cases.push({ id: requestId(), name, toolName: state.selected.name, arguments: payload, savedAt: new Date().toISOString() }); persistCases();
} catch (error) { alert(error.message); }
}
function renderCases() {
const list = $('caseList'); list.innerHTML = '';
if (!state.cases.length) { list.innerHTML = '<p style="margin:0;color:#a1a1aa;font-size:13px">저장된 케이스가 없습니다.</p>'; return; }
for (const item of state.cases) {
const row = document.createElement('div'); row.className = 'case-row';
row.innerHTML = `<main><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.toolName)}</small></main><button data-run="${item.id}">실행</button><button class="danger" data-delete="${item.id}">삭제</button>`;
list.appendChild(row);
}
}
async function runSavedCases() {
if (!state.cases.length) return alert('저장된 테스트 케이스가 없습니다.');
$('runAllButton').disabled = true; $('runLog').textContent = `${state.cases.length}건 실행 시작\n`;
let success = 0;
for (const item of state.cases) {
const tool = state.tools.find(candidate => candidate.name === item.toolName);
if (!tool) { $('runLog').textContent += `FAIL ${item.name} · Tool 없음\n`; continue; }
try { const execution = await execute(tool, item.arguments); success += execution.ok ? 1 : 0; $('runLog').textContent += `${execution.ok ? 'PASS' : 'FAIL'} ${item.name} · HTTP ${execution.status} · ${execution.elapsed}ms\n`; }
catch (error) { $('runLog').textContent += `FAIL ${item.name} · ${error.message}\n`; }
}
$('runLog').textContent += `완료: ${success}/${state.cases.length} 성공`; $('runAllButton').disabled = false;
}
select.addEventListener('change', () => choose(state.tools.find(tool => tool.name === select.value)));
$('filter').addEventListener('input', renderTools);
$('sampleButton').addEventListener('click', () => { if (state.selected) args.value = JSON.stringify(exampleForSchema(state.selected.inputSchema || state.selected.parametersSchema || {}), null, 2); });
$('reloadButton').addEventListener('click', loadManifest);
$('saveButton').addEventListener('click', saveCase);
$('executeButton').addEventListener('click', async () => { try { await execute(); } catch (error) { $('httpStatus').textContent = '입력 오류'; $('httpStatus').className = 'badge fail'; result.textContent = error.message; } });
$('runAllButton').addEventListener('click', runSavedCases);
$('clearCasesButton').addEventListener('click', () => { if (confirm('저장된 테스트 케이스를 모두 삭제할까요?')) { state.cases = []; persistCases(); } });
$('caseList').addEventListener('click', async event => { const id = event.target.dataset.run || event.target.dataset.delete; if (!id) return; const item = state.cases.find(candidate => candidate.id === id); if (event.target.dataset.delete) { state.cases = state.cases.filter(candidate => candidate.id !== id); persistCases(); return; } const tool = state.tools.find(candidate => candidate.name === item.toolName); try { await execute(tool, item.arguments); } catch (error) { result.textContent = error.message; } });
renderCases(); loadManifest();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
package io.shinhanlife.dap.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class ToolTestConsoleResourceTest {
@Test
void publishesManifestDrivenToolTestConsole() throws Exception {
try (InputStream resource = getClass().getResourceAsStream("/static/tool-test-console.html")) {
assertNotNull(resource);
String html = new String(resource.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(html.contains("/tool-manifest"));
assertTrue(html.contains("Run saved cases"));
assertTrue(html.contains("localStorage"));
assertTrue(html.contains("request-id"));
}
}
}