Some checks are pending
build-and-push / image (push) Waiting to run
- All 14 panes rewritten with rich controls: KPIs, sparklines, meter gauges, presets, history sidebars, search/filter, bulk actions, schema-driven forms, copy buttons, auto-refresh. - Backend /api/system enriched with cpu_count, rss_bytes, mem_total, mem_used_pct, loadavg[], user/system CPU times, max_rss_kb. - CSS additions: .meter/.gauge, .seg, .chip(s), .spark, .slider, .dl, .link-card, details.card-d plus utility classes. - ui.js helpers: copyToClipboard, withCopy/bindCopyButtons, sparkline, meterRow, fmtBytes/fmtNum, historyStore, inputFromSchema, collectSchemaForm. - Files pane added; iCloud duplicate '*2' files removed.
210 lines
8.1 KiB
JavaScript
210 lines
8.1 KiB
JavaScript
// panes/tools.js — MCP tool browser & invoker with schema-driven forms.
|
|
import { registerPane } from '../app.js';
|
|
import { mcp, escapeHtml } from '../lib/api.js';
|
|
import { fmtJSON, ok, err, copyToClipboard, inputFromSchema, collectSchemaForm, historyStore } from '../lib/ui.js';
|
|
|
|
const callHist = historyStore('cx_tool_calls', 30);
|
|
const FAV_KEY = 'cx_tool_favs';
|
|
const favs = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]'));
|
|
function persistFavs() { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])); }
|
|
|
|
const TPL = `
|
|
<div class="pane-head">
|
|
<div><div class="title">Tools</div><div class="sub">Discover and invoke MCP tools.</div></div>
|
|
<div class="grow"></div>
|
|
<button class="btn btn-secondary" id="tl-refresh">Reload</button>
|
|
</div>
|
|
|
|
<div class="grid" style="grid-template-columns: 320px 1fr; gap: 16px;">
|
|
<div class="card no-pad scroll" style="max-height:72vh">
|
|
<div class="p-2 flex gap-2">
|
|
<input class="input" id="tl-search" placeholder="filter tools…"/>
|
|
</div>
|
|
<div class="p-2">
|
|
<div class="seg" id="tl-seg">
|
|
<button class="active" data-g="all">All</button>
|
|
<button data-g="fav">Favorites</button>
|
|
<button data-g="recent">Recent</button>
|
|
</div>
|
|
</div>
|
|
<div id="tl-list">loading…</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">
|
|
<h2 id="tl-name">Select a tool</h2>
|
|
<button class="btn btn-ghost" id="tl-fav" hidden>★</button>
|
|
</div>
|
|
<p class="muted small" id="tl-desc">Choose a tool on the left.</p>
|
|
|
|
<div id="tl-body" hidden>
|
|
<div class="seg mb-2" id="tl-mode">
|
|
<button class="active" data-m="form">Form</button>
|
|
<button data-m="json">JSON</button>
|
|
</div>
|
|
|
|
<form id="tl-form" class="grid gap-3"></form>
|
|
<textarea id="tl-json" class="input" rows="8" placeholder='{"key":"value"}' hidden></textarea>
|
|
|
|
<div class="btn-row mt-3">
|
|
<button class="btn btn-primary" id="tl-call">Call</button>
|
|
<button class="btn btn-secondary" id="tl-copy-args">Copy args</button>
|
|
<button class="btn btn-ghost" id="tl-reset">Reset</button>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="card-title"><h2 style="font-size:14px">Result</h2><span id="tl-meta" class="muted xsmall"></span></div>
|
|
<pre id="tl-result" class="code" style="max-height:32vh">—</pre>
|
|
|
|
<details class="card-d mt-3"><summary>Recent calls</summary>
|
|
<div id="tl-hist" class="body"></div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
let allTools = [];
|
|
let current = null;
|
|
let mode = 'form';
|
|
let group = 'all';
|
|
|
|
function passes(t, q) {
|
|
const blob = (t.name + ' ' + (t.description || '')).toLowerCase();
|
|
if (q && !blob.includes(q)) return false;
|
|
if (group === 'fav' && !favs.has(t.name)) return false;
|
|
if (group === 'recent') {
|
|
const recents = new Set(callHist.list().map(r => r.tool));
|
|
if (!recents.has(t.name)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function renderList(host) {
|
|
const q = host.querySelector('#tl-search').value.trim().toLowerCase();
|
|
const items = allTools.filter(t => passes(t, q));
|
|
host.querySelector('#tl-list').innerHTML = items.length
|
|
? items.map(t => `<button class="nav-item" data-tool="${escapeHtml(t.name)}">
|
|
<span class="grow truncate"><span class="mono">${escapeHtml(t.name)}</span></span>
|
|
${favs.has(t.name) ? '<span class="muted">★</span>' : ''}
|
|
</button>`).join('')
|
|
: '<div class="muted xsmall p-2">no tools match</div>';
|
|
host.querySelectorAll('[data-tool]').forEach(b => b.addEventListener('click', () => select(host, b.dataset.tool)));
|
|
}
|
|
|
|
function renderForm(host) {
|
|
if (!current) return;
|
|
const f = host.querySelector('#tl-form');
|
|
const schema = current.inputSchema || current.input_schema || {};
|
|
const props = schema.properties || {};
|
|
const names = Object.keys(props);
|
|
if (!names.length) {
|
|
f.innerHTML = '<div class="muted xsmall">no input schema — call with empty args or use JSON</div>';
|
|
return;
|
|
}
|
|
f.innerHTML = names.map(n => inputFromSchema(n, props[n])).join('');
|
|
}
|
|
|
|
function renderHist(host) {
|
|
const list = callHist.list().filter(r => r.tool === current?.name);
|
|
host.querySelector('#tl-hist').innerHTML = list.length
|
|
? list.map((r, i) => `<button class="nav-item" data-hi="${i}">
|
|
<span class="muted xsmall">${new Date(r.t).toLocaleTimeString()}</span>
|
|
<span class="grow truncate mono xsmall">${escapeHtml(JSON.stringify(r.args))}</span>
|
|
</button>`).join('')
|
|
: '<div class="muted xsmall">no recent calls</div>';
|
|
host.querySelectorAll('#tl-hist [data-hi]').forEach(b => b.addEventListener('click', () => {
|
|
const r = list[+b.dataset.hi];
|
|
if (r) { host.querySelector('#tl-json').value = fmtJSON(r.args); switchMode(host, 'json'); }
|
|
}));
|
|
}
|
|
|
|
function select(host, name) {
|
|
current = allTools.find(t => t.name === name);
|
|
if (!current) return;
|
|
host.querySelector('#tl-name').textContent = current.name;
|
|
host.querySelector('#tl-desc').textContent = current.description || '—';
|
|
host.querySelector('#tl-body').hidden = false;
|
|
const favBtn = host.querySelector('#tl-fav');
|
|
favBtn.hidden = false;
|
|
favBtn.textContent = favs.has(name) ? '★ unfavorite' : '☆ favorite';
|
|
host.querySelector('#tl-json').value = '{}';
|
|
renderForm(host); renderHist(host);
|
|
}
|
|
|
|
function switchMode(host, m) {
|
|
mode = m;
|
|
host.querySelectorAll('#tl-mode button').forEach(b => b.classList.toggle('active', b.dataset.m === m));
|
|
host.querySelector('#tl-form').hidden = m !== 'form';
|
|
host.querySelector('#tl-json').hidden = m !== 'json';
|
|
}
|
|
|
|
function gatherArgs(host) {
|
|
if (mode === 'form') return collectSchemaForm(host.querySelector('#tl-form'));
|
|
const t = host.querySelector('#tl-json').value.trim();
|
|
if (!t) return {};
|
|
return JSON.parse(t);
|
|
}
|
|
|
|
async function load(host) {
|
|
try {
|
|
const j = await mcp.tools();
|
|
allTools = (j.tools || []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
renderList(host);
|
|
} catch (e) {
|
|
host.querySelector('#tl-list').innerHTML = `<div class="muted xsmall p-2">${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
registerPane('tools', {
|
|
label: 'Tools',
|
|
init(host) {
|
|
host.innerHTML = TPL;
|
|
host.querySelector('#tl-refresh').addEventListener('click', () => load(host));
|
|
host.querySelector('#tl-search').addEventListener('input', () => renderList(host));
|
|
host.querySelectorAll('#tl-seg button').forEach(b => b.addEventListener('click', () => {
|
|
host.querySelectorAll('#tl-seg button').forEach(x => x.classList.toggle('active', x === b));
|
|
group = b.dataset.g; renderList(host);
|
|
}));
|
|
host.querySelectorAll('#tl-mode button').forEach(b => b.addEventListener('click', () => switchMode(host, b.dataset.m)));
|
|
|
|
host.querySelector('#tl-fav').addEventListener('click', () => {
|
|
if (!current) return;
|
|
if (favs.has(current.name)) favs.delete(current.name); else favs.add(current.name);
|
|
persistFavs();
|
|
host.querySelector('#tl-fav').textContent = favs.has(current.name) ? '★ unfavorite' : '☆ favorite';
|
|
renderList(host);
|
|
});
|
|
|
|
host.querySelector('#tl-copy-args').addEventListener('click', () => {
|
|
try { copyToClipboard(fmtJSON(gatherArgs(host)), 'args copied'); } catch (e) { err(e.message); }
|
|
});
|
|
host.querySelector('#tl-reset').addEventListener('click', () => {
|
|
if (current) select(host, current.name);
|
|
});
|
|
|
|
host.querySelector('#tl-call').addEventListener('click', async () => {
|
|
if (!current) return;
|
|
let args; try { args = gatherArgs(host); } catch (e) { return err('args: ' + e.message); }
|
|
const t0 = performance.now();
|
|
const pre = host.querySelector('#tl-result');
|
|
pre.textContent = 'calling…';
|
|
try {
|
|
const j = await mcp.call(current.name, args);
|
|
const dt = (performance.now() - t0).toFixed(0);
|
|
host.querySelector('#tl-meta').textContent = `${dt} ms`;
|
|
pre.textContent = fmtJSON(j);
|
|
callHist.push({ t: Date.now(), tool: current.name, args });
|
|
renderHist(host);
|
|
ok('called ' + current.name);
|
|
} catch (e) {
|
|
pre.textContent = 'error: ' + e.message;
|
|
err(e.message);
|
|
}
|
|
});
|
|
|
|
load(host);
|
|
},
|
|
});
|