feat(terminal): add Terminal panel to Node Details with WebSocket
- Add 'Terminal' button to drawer header
- Implement TerminalPanel (bottom-up fade-in, ~1/3 viewport, left of drawer)
- Connect to ws(s)://{nodeIp}/ws; display incoming messages; input sends raw
- Wire Drawer to pass node IP and close terminal on drawer close
- Add styles/z-index and include script in index.html
This commit is contained in:
189
public/scripts/components/TerminalPanelComponent.js
Normal file
189
public/scripts/components/TerminalPanelComponent.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// Lightweight Terminal Panel singleton
|
||||
(() => {
|
||||
class TerminalPanelImpl {
|
||||
constructor() {
|
||||
this.container = null; // external container passed by DrawerComponent
|
||||
this.panelEl = null;
|
||||
this.logEl = null;
|
||||
this.inputEl = null;
|
||||
this.socket = null;
|
||||
this.connectedIp = null;
|
||||
this.isOpen = false;
|
||||
this.resizeHandler = null;
|
||||
}
|
||||
|
||||
open(container, nodeIp) {
|
||||
try {
|
||||
this.container = container;
|
||||
if (!this.container) return;
|
||||
|
||||
if (!this.panelEl) {
|
||||
this._buildPanel();
|
||||
}
|
||||
|
||||
// Adjust position to be left of the details drawer
|
||||
this._adjustRightOffset();
|
||||
if (!this.resizeHandler) {
|
||||
this.resizeHandler = () => this._adjustRightOffset();
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
|
||||
// Show panel with animation
|
||||
requestAnimationFrame(() => {
|
||||
this.panelEl.classList.add('visible');
|
||||
this.isOpen = true;
|
||||
this.inputEl && this.inputEl.focus();
|
||||
});
|
||||
|
||||
// Connect websocket
|
||||
if (nodeIp) {
|
||||
this._connect(nodeIp);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.open error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try {
|
||||
if (!this.isOpen) return;
|
||||
this.panelEl && this.panelEl.classList.remove('visible');
|
||||
this.isOpen = false;
|
||||
if (this.socket) {
|
||||
try { this.socket.close(); } catch (_) {}
|
||||
this.socket = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_buildPanel() {
|
||||
// Ensure container baseline positioning
|
||||
this.container.classList.add('terminal-panel-container');
|
||||
// Create panel DOM
|
||||
this.panelEl = document.createElement('div');
|
||||
this.panelEl.className = 'terminal-panel';
|
||||
this.panelEl.innerHTML = `
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">Terminal</div>
|
||||
<div class="terminal-actions">
|
||||
<button class="terminal-clear-btn" title="Clear">Clear</button>
|
||||
<button class="terminal-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<pre class="terminal-log"></pre>
|
||||
</div>
|
||||
<div class="terminal-input-row">
|
||||
<input type="text" class="terminal-input" placeholder="Type and press Enter to send" />
|
||||
<button class="terminal-send-btn">Send</button>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(this.panelEl);
|
||||
|
||||
this.logEl = this.panelEl.querySelector('.terminal-log');
|
||||
this.inputEl = this.panelEl.querySelector('.terminal-input');
|
||||
const sendBtn = this.panelEl.querySelector('.terminal-send-btn');
|
||||
const closeBtn = this.panelEl.querySelector('.terminal-close-btn');
|
||||
const clearBtn = this.panelEl.querySelector('.terminal-clear-btn');
|
||||
|
||||
const sendHandler = () => {
|
||||
const value = (this.inputEl && this.inputEl.value) || '';
|
||||
if (!value) return;
|
||||
this._send(value);
|
||||
this.inputEl.value = '';
|
||||
};
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', (e) => { e.stopPropagation(); sendHandler(); });
|
||||
if (this.inputEl) this.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendHandler();
|
||||
}
|
||||
});
|
||||
if (closeBtn) closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); });
|
||||
if (clearBtn) clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this._clear(); });
|
||||
}
|
||||
|
||||
_connect(nodeIp) {
|
||||
try {
|
||||
const titleEl = this.panelEl.querySelector('.terminal-title');
|
||||
if (titleEl) titleEl.textContent = `Terminal — ${nodeIp}`;
|
||||
|
||||
// Close previous socket if switching node
|
||||
if (this.socket) {
|
||||
try { this.socket.close(); } catch (_) {}
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
const protocol = (window.location && window.location.protocol === 'https:') ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${nodeIp}/ws`;
|
||||
this.connectedIp = nodeIp;
|
||||
this._appendLine(`[connecting] ${url}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
this.socket = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this._appendLine('[open] WebSocket connection established');
|
||||
});
|
||||
ws.addEventListener('message', (evt) => {
|
||||
const data = typeof evt.data === 'string' ? evt.data : '[binary]';
|
||||
this._appendLine(data);
|
||||
});
|
||||
ws.addEventListener('error', (evt) => {
|
||||
this._appendLine('[error] WebSocket error');
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
this._appendLine('[close] WebSocket connection closed');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel._connect error:', err);
|
||||
this._appendLine(`[error] ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
_send(text) {
|
||||
try {
|
||||
if (!this.socket || this.socket.readyState !== 1) {
|
||||
this._appendLine('[warn] Socket not open');
|
||||
return;
|
||||
}
|
||||
this.socket.send(text);
|
||||
this._appendLine(`> ${text}`);
|
||||
} catch (err) {
|
||||
this._appendLine(`[error] ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
_clear() {
|
||||
if (this.logEl) this.logEl.textContent = '';
|
||||
}
|
||||
|
||||
_appendLine(line) {
|
||||
if (!this.logEl) return;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this.logEl.textContent += `[${timestamp}] ${line}\n`;
|
||||
// Auto-scroll to bottom
|
||||
const bodyEl = this.panelEl.querySelector('.terminal-body');
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
|
||||
_adjustRightOffset() {
|
||||
try {
|
||||
const drawer = document.querySelector('.details-drawer');
|
||||
const isOpen = drawer && drawer.classList.contains('open');
|
||||
const width = isOpen ? drawer.getBoundingClientRect().width : 0;
|
||||
if (this.container) {
|
||||
this.container.style.right = `${width}px`;
|
||||
}
|
||||
} catch (_) { /* no-op */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Expose singleton API
|
||||
window.TerminalPanel = window.TerminalPanel || new TerminalPanelImpl();
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user