CxWebApp/static/js/panes/websocket.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

182 lines
7.7 KiB
JavaScript

// panes/websocket.js — generic WebSocket console with URL switcher, presets,
// JSON pretty-print, auto-reconnect, message history.
import { registerPane } from '../app.js';
import { escapeHtml } from '../lib/api.js';
import { ok, err, copyToClipboard, historyStore } from '../lib/ui.js';
const ENDPOINTS = [
{ label: 'echo', url: '/ws/echo' },
];
const sendHistory = historyStore('cx_ws_send_history', 30);
const TPL = `
<div class="pane-head">
<div><div class="title">WebSocket</div><div class="sub">Connect to any WebSocket endpoint and inspect frames.</div></div>
<div class="grow"></div>
<span class="pill" id="ws-pill"><span class="dot"></span><span id="ws-state">idle</span></span>
</div>
<div class="card mb-3">
<div class="flex gap-2 mb-2">
<select class="input" id="ws-preset" style="max-width:160px"></select>
<input class="input" id="ws-url" placeholder="ws:// or wss:// URL"/>
<button class="btn btn-primary" id="ws-connect">Connect</button>
<button class="btn btn-secondary" id="ws-disconnect">Disconnect</button>
</div>
<div class="flex gap-3 items-center" style="flex-wrap:wrap">
<label class="check"><input type="checkbox" id="ws-autoreconnect"/> auto-reconnect</label>
<label class="check"><input type="checkbox" id="ws-pretty" checked/> pretty-print JSON</label>
<label class="check"><input type="checkbox" id="ws-ts" checked/> show timestamps</label>
<span class="muted xsmall" id="ws-stats">0 in · 0 out · 0 b</span>
<div class="grow"></div>
<button class="btn btn-ghost" id="ws-clear">Clear log</button>
<button class="btn btn-ghost" id="ws-copy">Copy log</button>
</div>
</div>
<div class="grid" style="grid-template-columns: 1fr 280px; gap: 16px;">
<div class="card">
<div class="card-title"><h2>Frames</h2><span class="muted mono xsmall" id="ws-frame-count">0</span></div>
<pre class="console" id="ws-log" style="height:48vh; max-height:60vh; overflow:auto"></pre>
<form id="ws-form" class="flex gap-2 mt-3">
<textarea class="input" id="ws-input" rows="2" placeholder="message — Enter to send, Shift+Enter for newline"></textarea>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
<div class="card">
<div class="card-title"><h2>Send presets</h2></div>
<div class="btn-row mb-3">
<button class="btn btn-secondary" data-q='ping'>ping</button>
<button class="btn btn-secondary" data-q='{"cmd":"hello"}'>hello (JSON)</button>
<button class="btn btn-secondary" data-q='subscribe events'>subscribe</button>
</div>
<div class="card-title"><h2>History</h2><span class="muted xsmall" id="ws-hist-count">0</span></div>
<div id="ws-history" style="max-height:30vh; overflow:auto"></div>
</div>
</div>
`;
let ws = null;
let stats = { in: 0, out: 0, bytes: 0 };
let reconnectTimer = null;
function setState(host, state, klass) {
host.querySelector('#ws-state').textContent = state;
const p = host.querySelector('#ws-pill');
p.classList.remove('ok', 'err', 'warn', 'info', 'muted');
p.classList.add(klass || 'muted');
}
function updateStats(host) {
host.querySelector('#ws-stats').textContent = `${stats.in} in · ${stats.out} out · ${stats.bytes} b`;
host.querySelector('#ws-frame-count').textContent = `${stats.in + stats.out}`;
}
function maybePretty(host, raw) {
if (!host.querySelector('#ws-pretty').checked) return raw;
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
}
function append(host, dir, raw) {
const pre = host.querySelector('#ws-log');
const showTs = host.querySelector('#ws-ts').checked;
const text = maybePretty(host, raw);
const ts = showTs ? `[${new Date().toISOString().slice(11, 23)}] ` : '';
const arrow = dir === 'in' ? '←' : dir === 'out' ? '→' : '·';
pre.textContent += `${ts}${arrow} ${text}\n`;
pre.scrollTop = pre.scrollHeight;
}
function defaultUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/ws/echo`;
}
function connect(host) {
if (ws && ws.readyState <= 1) return;
let url = host.querySelector('#ws-url').value.trim();
if (url.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = `${proto}//${location.host}${url}`;
}
if (!url) url = defaultUrl();
setState(host, 'connecting…', 'warn');
append(host, '·', `connecting ${url}`);
try { ws = new WebSocket(url); } catch (e) { setState(host, 'error', 'err'); append(host, '·', e.message); return; }
ws.onopen = () => { setState(host, 'connected', 'ok'); append(host, '·', 'connected'); };
ws.onclose = (e) => {
setState(host, `closed (${e.code})`, 'err');
append(host, '·', `closed code=${e.code} reason=${e.reason}`);
if (host.querySelector('#ws-autoreconnect').checked) {
reconnectTimer = setTimeout(() => connect(host), 2000);
}
};
ws.onerror = () => append(host, '·', 'error');
ws.onmessage = (e) => { stats.in++; stats.bytes += (e.data?.length || 0); updateStats(host); append(host, 'in', e.data); };
}
function disconnect() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
try { ws?.close(); } catch {}
}
function renderHistory(host) {
const items = sendHistory.list();
host.querySelector('#ws-hist-count').textContent = String(items.length);
host.querySelector('#ws-history').innerHTML = items.length
? items.map((h, i) => `<button class="nav-item" data-hi="${i}"><span class="truncate" style="font-size:12px">${escapeHtml(h.text)}</span></button>`).join('')
: '<div class="muted xsmall p-2">empty</div>';
host.querySelectorAll('#ws-history [data-hi]').forEach(b => {
b.addEventListener('click', () => {
host.querySelector('#ws-input').value = sendHistory.list()[+b.dataset.hi]?.text || '';
});
});
}
function send(host, text) {
if (!ws || ws.readyState !== 1) return err('not connected');
ws.send(text);
stats.out++; stats.bytes += text.length; updateStats(host);
append(host, 'out', text);
sendHistory.push({ text });
renderHistory(host);
}
registerPane('websocket', {
label: 'WebSocket',
init(host) {
host.innerHTML = TPL;
const sel = host.querySelector('#ws-preset');
sel.innerHTML = ENDPOINTS.map((e, i) => `<option value="${i}">${escapeHtml(e.label)} (${escapeHtml(e.url)})</option>`).join('');
host.querySelector('#ws-url').value = defaultUrl();
sel.addEventListener('change', () => {
const e = ENDPOINTS[+sel.value]; if (!e) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
host.querySelector('#ws-url').value = `${proto}//${location.host}${e.url}`;
});
host.querySelector('#ws-connect').addEventListener('click', () => connect(host));
host.querySelector('#ws-disconnect').addEventListener('click', disconnect);
host.querySelector('#ws-clear').addEventListener('click', () => {
host.querySelector('#ws-log').textContent = '';
stats = { in: 0, out: 0, bytes: 0 }; updateStats(host);
});
host.querySelector('#ws-copy').addEventListener('click', () => copyToClipboard(host.querySelector('#ws-log').textContent));
host.querySelector('#ws-form').addEventListener('submit', (e) => {
e.preventDefault();
const v = host.querySelector('#ws-input').value;
if (!v) return;
send(host, v);
host.querySelector('#ws-input').value = '';
});
host.querySelector('#ws-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host.querySelector('#ws-form').requestSubmit(); }
});
host.querySelectorAll('[data-q]').forEach(b => b.addEventListener('click', () => send(host, b.dataset.q)));
renderHistory(host);
updateStats(host);
},
});