diff --git a/assets/cluster.png b/assets/cluster.png index d52258e..b3f85e6 100644 Binary files a/assets/cluster.png and b/assets/cluster.png differ diff --git a/public/index.html b/public/index.html index 5009255..c43be27 100644 --- a/public/index.html +++ b/public/index.html @@ -177,6 +177,7 @@ + diff --git a/public/scripts/components/DrawerComponent.js b/public/scripts/components/DrawerComponent.js index 56b80e3..33a3e55 100644 --- a/public/scripts/components/DrawerComponent.js +++ b/public/scripts/components/DrawerComponent.js @@ -29,16 +29,19 @@ class DrawerComponent { this.detailsDrawer = document.createElement('div'); this.detailsDrawer.className = 'details-drawer'; - // Header with close button + // Header with actions and close button const header = document.createElement('div'); header.className = 'details-drawer-header'; header.innerHTML = `
Node Details
- + + + `; this.detailsDrawer.appendChild(header); @@ -47,11 +50,28 @@ class DrawerComponent { this.detailsDrawerContent.className = 'details-drawer-content'; this.detailsDrawer.appendChild(this.detailsDrawerContent); + // Terminal panel container (positioned left of details drawer) + this.terminalPanelContainer = document.createElement('div'); + this.terminalPanelContainer.className = 'terminal-panel-container'; + document.body.appendChild(this.terminalPanelContainer); + document.body.appendChild(this.detailsDrawer); // Close handlers const close = () => this.closeDrawer(); header.querySelector('.drawer-close').addEventListener('click', close); + const terminalBtn = header.querySelector('.drawer-terminal-btn'); + if (terminalBtn) { + terminalBtn.addEventListener('click', (e) => { + e.stopPropagation(); + try { + const nodeIp = this.activeDrawerComponent && this.activeDrawerComponent.viewModel && this.activeDrawerComponent.viewModel.get('nodeIp'); + window.TerminalPanel && window.TerminalPanel.open(this.terminalPanelContainer, nodeIp); + } catch (err) { + console.error('Failed to open terminal:', err); + } + }); + } this.detailsDrawerBackdrop.addEventListener('click', close); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); @@ -104,6 +124,9 @@ class DrawerComponent { if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible'); + // Also close terminal panel if open + try { if (window.TerminalPanel) window.TerminalPanel.close(); } catch (_) {} + // Call close callback if provided if (this.onCloseCallback) { this.onCloseCallback(); diff --git a/public/scripts/components/TerminalPanelComponent.js b/public/scripts/components/TerminalPanelComponent.js new file mode 100644 index 0000000..30b0871 --- /dev/null +++ b/public/scripts/components/TerminalPanelComponent.js @@ -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 = ` +
+
Terminal
+
+ + +
+
+
+

+                
+
+ + +
+ `; + 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(); +})(); + + diff --git a/public/styles/main.css b/public/styles/main.css index f87cf1f..1a8123b 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3298,6 +3298,23 @@ select.param-input:focus { .details-drawer-header .drawer-title { font-weight: 600; } +.details-drawer-header .drawer-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} +.drawer-terminal-btn { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.35rem 0.6rem; + cursor: pointer; +} +.drawer-terminal-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} .drawer-close { background: transparent; border: 1px solid var(--border-secondary); @@ -3323,6 +3340,108 @@ select.param-input:focus { .details-drawer-backdrop { display: none; } } +/* Terminal Panel - bottom up, left of drawer, 1/3 viewport height */ +.terminal-panel-container { + position: fixed; + inset: auto 0 0 auto; /* bottom-right anchored; right adjusted dynamically */ + right: 0; /* updated at runtime to drawer width */ + width: clamp(33.333vw, 600px, 90vw); /* match drawer width by default */ + z-index: 1001; /* above drawer (1000) and backdrop (999) */ + pointer-events: none; /* allow clicks only on panel */ +} + +.terminal-panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 33vh; /* 1/3 of viewport height */ + max-height: 40vh; + background: var(--bg-primary); + color: var(--text-primary); + border-top: 1px solid var(--border-primary); + border-left: 1px solid var(--border-primary); + box-shadow: 0 -8px 20px rgba(0,0,0,0.25); + transform: translateY(100%); + opacity: 0; + transition: transform 0.25s ease, opacity 0.25s ease; + border-top-left-radius: 10px; + pointer-events: auto; + display: flex; + flex-direction: column; +} +.terminal-panel.visible { + transform: translateY(0); + opacity: 1; +} +.terminal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-secondary); + background: var(--bg-secondary); +} +.terminal-title { + font-weight: 600; + font-size: 0.95rem; +} +.terminal-actions { + display: flex; + gap: 0.5rem; +} +.terminal-actions button { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 6px; + padding: 0.25rem 0.5rem; + cursor: pointer; +} +.terminal-actions button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.terminal-body { + flex: 1; + overflow: auto; + padding: 0.5rem 0.75rem; +} +.terminal-log { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: "Courier New", monospace; + font-size: 0.85rem; + color: var(--text-primary); +} +.terminal-input-row { + display: flex; + gap: 0.5rem; + border-top: 1px solid var(--border-secondary); + padding: 0.5rem; + background: var(--bg-secondary); +} +.terminal-input { + flex: 1; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-secondary); + border-radius: 6px; + padding: 0.4rem 0.6rem; +} +.terminal-send-btn { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + color: white; + border: none; + border-radius: 6px; + padding: 0.4rem 0.6rem; + cursor: pointer; +} +.terminal-send-btn:hover { + filter: brightness(1.05); +} + /* Topology hover tooltip for labels */ .topology-tooltip { position: fixed;