// panes/api.js — interactive HTTP explorer with presets, headers, history, share.
import { registerPane } from '../app.js';
import { jraw, escapeHtml } from '../lib/api.js';
import { fmtJSON, ok, err, copyToClipboard, historyStore, withCopy, bindCopyButtons } from '../lib/ui.js';
const PRESETS = [
{ label: 'health', method: 'GET', path: '/api/health' },
{ label: 'version', method: 'GET', path: '/api/version' },
{ label: 'system', method: 'GET', path: '/api/system' },
{ label: 'services', method: 'GET', path: '/api/services' },
{ label: 'items', method: 'GET', path: '/api/items' },
{ label: 'create item', method: 'POST', path: '/api/items',
body: '{\n "name": "demo",\n "description": "from API explorer"\n}' },
{ label: 'mac info', method: 'GET', path: '/api/mac/info' },
{ label: 'echo', method: 'POST', path: '/api/echo', body: '{"hello":"world"}' },
{ label: 'diffusion health', method: 'GET', path: '/api/diffusion/healthz' },
{ label: 'demand reports', method: 'GET', path: '/api/demand/reports' },
{ label: 'lang pipelines', method: 'GET', path: '/api/lang/pipelines' },
{ label: 'slack info', method: 'GET', path: '/api/slack/info' },
{ label: 'files providers', method: 'GET', path: '/api/files/mcp_providers?select=*' },
];
const history = historyStore('cx_api_history', 30);
const TPL = `
API Explorer
Hit any backend route — auto-saves history, supports custom headers and presets.
One per line, format Header: value. Defaults Content-Type: application/json + Accept: application/json are added automatically.
Auto-appended to the URL. key=value, one per line.
Response
—
no request yet
no request yet
`;
let prettyMode = true;
let lastResponse = null;
let lastRequest = null;
function parseKVLines(s, sep) {
return (s || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => {
const i = l.indexOf(sep);
return i < 0 ? null : [l.slice(0, i).trim(), l.slice(i + sep.length).trim()];
}).filter(Boolean);
}
function buildUrl(host, path, paramsText) {
const params = parseKVLines(paramsText, '=');
if (!params.length) return path;
const usp = new URLSearchParams();
for (const [k, v] of params) usp.append(k, v);
return path + (path.includes('?') ? '&' : '?') + usp.toString();
}
function asCurl(req) {
const parts = ['curl', '-X', req.method, JSON.stringify(req.url)];
for (const [k, v] of (req.headers || [])) parts.push('-H', JSON.stringify(`${k}: ${v}`));
if (req.body) parts.push('--data-raw', JSON.stringify(req.body));
return parts.join(' ');
}
function renderResponse(host) {
const out = host.querySelector('#ax-out');
if (!lastResponse) { out.textContent = 'no response yet'; return; }
const { body, raw } = lastResponse;
if (prettyMode) out.textContent = typeof body === 'string' ? body : fmtJSON(body);
else out.textContent = raw;
}
function renderHistory(host) {
const items = history.list();
host.querySelector('#ax-hist-count').textContent = String(items.length);
host.querySelector('#ax-history').innerHTML = items.length
? items.map((h, i) => ``).join('')
: 'empty
';
host.querySelectorAll('#ax-history [data-hi]').forEach(b => {
b.addEventListener('click', () => {
const it = history.list()[+b.dataset.hi];
if (!it) return;
host.querySelector('#ax-method').value = it.method;
host.querySelector('#ax-path').value = it.path;
host.querySelector('#ax-body').value = it.body || '';
});
});
}
function renderPresets(host) {
host.querySelector('#ax-presets').innerHTML = PRESETS.map((p, i) =>
``
).join('');
host.querySelectorAll('#ax-presets [data-pi]').forEach(b => {
b.addEventListener('click', () => {
const p = PRESETS[+b.dataset.pi];
if (!p) return;
host.querySelector('#ax-method').value = p.method;
host.querySelector('#ax-path').value = p.path;
host.querySelector('#ax-body').value = p.body || '';
});
});
}
async function send(host) {
const method = host.querySelector('#ax-method').value;
const path = host.querySelector('#ax-path').value.trim();
const url = buildUrl(host, path, host.querySelector('#ax-params').value);
const bodyTxt = host.querySelector('#ax-body').value.trim();
const headers = parseKVLines(host.querySelector('#ax-headers').value, ':');
if (!path) return err('path required');
host.querySelector('#ax-status').textContent = '…';
host.querySelector('#ax-resp-meta').textContent = 'sending…';
const t0 = performance.now();
// jraw uses fetch but lib helper doesn't accept custom headers; do it directly for full control.
const init = { method };
const hdr = { Accept: 'application/json' };
if (bodyTxt && method !== 'GET' && method !== 'HEAD') {
hdr['Content-Type'] = 'application/json';
init.body = bodyTxt;
}
for (const [k, v] of headers) hdr[k] = v;
init.headers = hdr;
try {
const r = await fetch(url, init);
const text = await r.text();
let parsed; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
const dt = Math.round(performance.now() - t0);
lastResponse = { status: r.status, body: parsed, raw: text };
lastRequest = { method, url, headers: Object.entries(hdr), body: bodyTxt };
const pill = host.querySelector('#ax-status');
pill.classList.remove('ok', 'warn', 'err', 'muted');
pill.classList.add(r.ok ? 'ok' : (r.status >= 500 ? 'err' : 'warn'));
pill.textContent = `${r.status}`;
host.querySelector('#ax-resp-meta').textContent =
`${method} ${url} · ${dt}ms · ${text.length}b · ${r.headers.get('content-type') || ''}`;
renderResponse(host);
history.push({ method, path, body: bodyTxt, status: r.status });
renderHistory(host);
ok(`${method} ${path} → ${r.status}`);
} catch (e) {
host.querySelector('#ax-status').textContent = 'ERR';
host.querySelector('#ax-resp-meta').textContent = 'network: ' + e.message;
host.querySelector('#ax-out').textContent = e.message;
err(e.message);
}
}
function applyShareHash(host) {
const m = location.hash.match(/api\?(.*)$/);
if (!m) return;
const p = new URLSearchParams(m[1]);
if (p.get('m')) host.querySelector('#ax-method').value = p.get('m');
if (p.get('p')) host.querySelector('#ax-path').value = p.get('p');
if (p.get('b')) try { host.querySelector('#ax-body').value = atob(p.get('b')); } catch {}
}
registerPane('api', {
label: 'API Explorer',
init(host) {
host.innerHTML = TPL;
renderPresets(host);
renderHistory(host);
applyShareHash(host);
host.querySelector('#ax-send').addEventListener('click', () => send(host));
host.querySelector('#ax-path').addEventListener('keydown', e => { if (e.key === 'Enter') send(host); });
// tab switching
host.querySelectorAll('#ax-tabs button').forEach(b => {
b.addEventListener('click', () => {
host.querySelectorAll('#ax-tabs button').forEach(x => x.classList.toggle('active', x === b));
host.querySelectorAll('[data-pane-tab]').forEach(el => el.style.display = el.dataset.paneTab === b.dataset.tab ? '' : 'none');
});
});
host.querySelector('#ax-fmt').addEventListener('click', () => {
const ta = host.querySelector('#ax-body');
try { ta.value = JSON.stringify(JSON.parse(ta.value), null, 2); } catch (e) { err('bad JSON: ' + e.message); }
});
host.querySelector('#ax-copy-body').addEventListener('click', () => copyToClipboard(host.querySelector('#ax-body').value));
host.querySelector('#ax-copy-resp').addEventListener('click', () => copyToClipboard(host.querySelector('#ax-out').textContent));
host.querySelector('#ax-as-curl').addEventListener('click', () => {
const url = buildUrl(host, host.querySelector('#ax-path').value, host.querySelector('#ax-params').value);
const method = host.querySelector('#ax-method').value;
const body = host.querySelector('#ax-body').value;
const headers = parseKVLines(host.querySelector('#ax-headers').value, ':');
copyToClipboard(asCurl({ method, url, body, headers }), 'curl copied');
});
host.querySelector('#ax-resp-pretty').addEventListener('click', () => { prettyMode = !prettyMode; renderResponse(host); });
host.querySelector('#ax-clear-hist').addEventListener('click', () => { history.clear(); renderHistory(host); ok('history cleared'); });
host.querySelector('#ax-share').addEventListener('click', () => {
const m = host.querySelector('#ax-method').value;
const p = host.querySelector('#ax-path').value;
const b = host.querySelector('#ax-body').value;
const usp = new URLSearchParams({ m, p });
if (b) usp.set('b', btoa(b));
const url = `${location.origin}/#api?${usp.toString()}`;
copyToClipboard(url, 'share link copied');
});
},
});