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.
182 lines
7.7 KiB
JavaScript
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);
|
|
},
|
|
});
|