CxWebApp/static/js/panes/api.js
CxAI Agent 75153b7fe9
Some checks are pending
build-and-push / image (push) Waiting to run
feat(cxwebapp): comprehensive pane enhancements
- 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.
2026-05-17 09:36:19 -05:00

269 lines
12 KiB
JavaScript

// 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 = `
<div class="pane-head">
<div><div class="title">API Explorer</div><div class="sub">Hit any backend route — auto-saves history, supports custom headers and presets.</div></div>
<div class="grow"></div>
<button class="btn btn-ghost" id="ax-clear-hist">Clear history</button>
<button class="btn btn-secondary" id="ax-share">Share link</button>
</div>
<div class="grid" style="grid-template-columns: 240px 1fr; gap: 16px;">
<div class="card no-pad" style="padding: 8px;">
<div class="card-title" style="padding: 8px 10px;"><h2 style="font-size:13px">Presets</h2></div>
<div id="ax-presets" style="display:flex; flex-direction:column; gap:2px"></div>
<div class="divider"></div>
<div class="card-title" style="padding: 4px 10px;"><h2 style="font-size:13px">History</h2><span class="muted xsmall" id="ax-hist-count">0</span></div>
<div id="ax-history" style="display:flex; flex-direction:column; gap:2px; max-height:40vh; overflow:auto"></div>
</div>
<div>
<div class="card mb-3">
<div class="flex gap-2 mb-3">
<select class="input" id="ax-method" style="max-width:120px">
<option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option><option>HEAD</option>
</select>
<input class="input" id="ax-path" value="/api/health" placeholder="/api/…"/>
<button class="btn btn-primary" id="ax-send">Send</button>
</div>
<div class="seg mb-3" id="ax-tabs">
<button class="active" data-tab="body">Body</button>
<button data-tab="headers">Headers</button>
<button data-tab="params">Query</button>
</div>
<div data-pane-tab="body">
<label class="check mb-2"><input type="checkbox" id="ax-pretty" checked/> pretty-print outgoing JSON on Format</label>
<textarea class="input" id="ax-body" rows="8" placeholder='{"key": "value"}'></textarea>
<div class="btn-row mt-2">
<button class="btn btn-ghost" id="ax-fmt">Format JSON</button>
<button class="btn btn-ghost" id="ax-copy-body">Copy</button>
<button class="btn btn-ghost" id="ax-as-curl">Copy as cURL</button>
</div>
</div>
<div data-pane-tab="headers" style="display:none">
<div class="muted xsmall mb-2">One per line, format <code>Header: value</code>. Defaults <code>Content-Type: application/json</code> + <code>Accept: application/json</code> are added automatically.</div>
<textarea class="input" id="ax-headers" rows="6" placeholder="X-Trace-Id: abc-123"></textarea>
</div>
<div data-pane-tab="params" style="display:none">
<div class="muted xsmall mb-2">Auto-appended to the URL. <code>key=value</code>, one per line.</div>
<textarea class="input" id="ax-params" rows="6" placeholder="limit=10"></textarea>
</div>
</div>
<div class="card">
<div class="card-title">
<h2>Response</h2>
<span class="flex gap-2 items-center">
<span class="pill muted" id="ax-status">—</span>
<button class="btn btn-ghost" id="ax-copy-resp">Copy</button>
<button class="btn btn-ghost" id="ax-resp-pretty">Toggle pretty</button>
</span>
</div>
<div id="ax-resp-meta" class="muted xsmall mb-2">no request yet</div>
<pre id="ax-out" class="code" style="max-height:55vh">no request yet</pre>
</div>
</div>
</div>
`;
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) => `<button class="nav-item" data-hi="${i}" title="${escapeHtml(h.path)}">
<span class="pill ${h.status >= 200 && h.status < 300 ? 'ok' : h.status >= 400 ? 'err' : 'warn'}" style="font-size:10px">${h.method}</span>
<span class="truncate" style="font-size:12px">${escapeHtml(h.path)}</span>
</button>`).join('')
: '<div class="muted xsmall p-2">empty</div>';
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) =>
`<button class="nav-item" data-pi="${i}"><span class="pill info" style="font-size:10px">${p.method}</span><span class="truncate" style="font-size:12px">${escapeHtml(p.label)}</span></button>`
).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');
});
},
});