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.
269 lines
12 KiB
JavaScript
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');
|
|
});
|
|
},
|
|
});
|