// 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.

Presets

History

0
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'); }); }, });