From a26ef3949a422b052d0b287d24b1406ff1fa807d Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Tue, 30 Sep 2025 21:32:30 +0200 Subject: [PATCH 1/2] feat: add member terminal trigger and align terminal panel bottom-center --- .../components/ClusterMembersComponent.js | 50 ++++++++++++-- .../components/TerminalPanelComponent.js | 23 ++----- public/styles/main.css | 69 ++++++++++++++----- 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index ff5f222..f68e49b 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -23,6 +23,9 @@ class ClusterMembersComponent extends Component { // Drawer state for desktop this.drawer = new DrawerComponent(); + // Terminal panel container (shared with drawer) + this.terminalPanelContainer = null; + // Selection state for highlighting this.selectedMemberIp = null; } @@ -403,7 +406,6 @@ class ClusterMembersComponent extends Component { const membersHTML = members.map(member => { const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline'; - const statusText = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'Online' : 'Offline'; const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : '🔴'; logger.debug('ClusterMembersComponent: Rendering member:', member); @@ -433,10 +435,18 @@ class ClusterMembersComponent extends Component { ` : ''} -
- - - +
+ +
+ + + +
@@ -455,6 +465,7 @@ class ClusterMembersComponent extends Component { setupMemberCards(members) { setTimeout(() => { this.findAllElements('.member-card').forEach((card, index) => { + const terminalBtn = card.querySelector('.member-terminal-btn'); const expandIcon = card.querySelector('.expand-icon'); const memberDetails = card.querySelector('.member-details'); const memberIp = card.dataset.memberIp; @@ -505,6 +516,21 @@ class ClusterMembersComponent extends Component { } }); } + + if (terminalBtn) { + this.addEventListener(terminalBtn, 'click', (e) => { + e.stopPropagation(); + e.preventDefault(); + try { + if (window.TerminalPanel) { + this.ensureTerminalContainer(); + window.TerminalPanel.open(this.terminalPanelContainer, memberIp); + } + } catch (err) { + console.error('Failed to open member terminal:', err); + } + }); + } }); }, 100); } @@ -722,6 +748,20 @@ class ClusterMembersComponent extends Component { this.selectedMemberIp = null; } } + + ensureTerminalContainer() { + if (!this.terminalPanelContainer) { + try { + const drawer = this.drawer; + if (drawer && drawer.ensureDrawer) { + drawer.ensureDrawer(); + this.terminalPanelContainer = drawer.terminalPanelContainer; + } + } catch (err) { + console.error('Failed to ensure terminal container:', err); + } + } + } } window.ClusterMembersComponent = ClusterMembersComponent; \ No newline at end of file diff --git a/public/scripts/components/TerminalPanelComponent.js b/public/scripts/components/TerminalPanelComponent.js index 50403d1..922baff 100644 --- a/public/scripts/components/TerminalPanelComponent.js +++ b/public/scripts/components/TerminalPanelComponent.js @@ -21,11 +21,13 @@ 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); + // Reset any leftover inline positioning so CSS centering applies + if (this.container) { + this.container.style.right = ''; + this.container.style.left = ''; + this.container.style.top = ''; + this.container.style.bottom = ''; + this.container.style.width = ''; } // Show panel with animation @@ -193,17 +195,6 @@ return text; } } - - _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 diff --git a/public/styles/main.css b/public/styles/main.css index 1a8123b..f05d446 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -333,12 +333,46 @@ p { display: flex; justify-content: space-between; align-items: flex-start; + gap: 0.75rem; } .member-info { flex: 1; } +.member-actions { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.member-terminal-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: 6px; + border: 1px solid var(--border-primary); + background: rgba(255, 255, 255, 0.05); + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.2s ease; +} + +.member-terminal-btn:hover, +.member-terminal-btn:focus-visible { + background: rgba(255, 255, 255, 0.12); + color: var(--text-secondary); + border-color: rgba(255, 255, 255, 0.2); +} + +.member-terminal-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + stroke-width: 2; +} + .expand-icon { color: var(--text-tertiary); cursor: pointer; @@ -3340,40 +3374,41 @@ select.param-input:focus { .details-drawer-backdrop { display: none; } } -/* Terminal Panel - bottom up, left of drawer, 1/3 viewport height */ +/* Terminal Panel - bottom-centered modal style */ .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 */ + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 1.5rem; + z-index: 1001; + pointer-events: none; } .terminal-panel { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 33vh; /* 1/3 of viewport height */ - max-height: 40vh; + position: relative; + width: min(720px, 90vw); + height: min(45vh, 520px); + max-height: 65vh; 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%); + border: 1px solid var(--border-primary); + box-shadow: 0 18px 40px rgba(0,0,0,0.35); + transform: translateY(32px); opacity: 0; transition: transform 0.25s ease, opacity 0.25s ease; - border-top-left-radius: 10px; + border-radius: 12px; pointer-events: auto; display: flex; flex-direction: column; } + .terminal-panel.visible { transform: translateY(0); opacity: 1; } + .terminal-header { display: flex; align-items: center; From 96e064181969c82ffbed3cd91f7cc784b10dafd8 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Tue, 30 Sep 2025 21:49:09 +0200 Subject: [PATCH 2/2] feat: minimize terminal --- .../components/ClusterMembersComponent.js | 14 ++- public/scripts/components/DrawerComponent.js | 18 ++- .../components/TerminalPanelComponent.js | 110 ++++++++++++++++-- public/styles/main.css | 89 +++++++++++++- 4 files changed, 211 insertions(+), 20 deletions(-) diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index f68e49b..2f3ccc6 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -518,14 +518,18 @@ class ClusterMembersComponent extends Component { } if (terminalBtn) { - this.addEventListener(terminalBtn, 'click', (e) => { + this.addEventListener(terminalBtn, 'click', async (e) => { e.stopPropagation(); e.preventDefault(); try { - if (window.TerminalPanel) { - this.ensureTerminalContainer(); - window.TerminalPanel.open(this.terminalPanelContainer, memberIp); - } + if (!window.TerminalPanel) return; + this.ensureTerminalContainer(); + const panel = window.TerminalPanel; + const wasMinimized = panel.isMinimized; + panel.open(this.terminalPanelContainer, memberIp); + if (wasMinimized && panel.restore) { + panel.restore(); + } } catch (err) { console.error('Failed to open member terminal:', err); } diff --git a/public/scripts/components/DrawerComponent.js b/public/scripts/components/DrawerComponent.js index 33a3e55..cb0d359 100644 --- a/public/scripts/components/DrawerComponent.js +++ b/public/scripts/components/DrawerComponent.js @@ -35,7 +35,12 @@ class DrawerComponent { header.innerHTML = `
Node Details
- + +
@@ -79,6 +102,7 @@
+
`; @@ -89,6 +113,7 @@ 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 minimizeBtn = this.panelEl.querySelector('.terminal-minimize-btn'); const sendHandler = () => { const value = (this.inputEl && this.inputEl.value) || ''; @@ -106,6 +131,7 @@ }); if (closeBtn) closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); }); if (clearBtn) clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this._clear(); }); + if (minimizeBtn) minimizeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.minimize(); }); } _connect(nodeIp) { @@ -160,6 +186,74 @@ } } + minimize() { + try { + if (!this.panelEl || this.isMinimized) return; + this.panelEl.classList.remove('visible'); + this.panelEl.classList.add('minimized'); + this.isMinimized = true; + this.isOpen = true; + this._showDock(); + } catch (err) { + console.error('TerminalPanel.minimize error:', err); + } + } + + restore() { + try { + if (!this.panelEl || !this.isMinimized) return; + this.panelEl.classList.remove('minimized'); + requestAnimationFrame(() => { + this.panelEl.classList.add('visible'); + this.inputEl && this.inputEl.focus(); + }); + this.isMinimized = false; + this.isOpen = true; + this._hideDock(); + } catch (err) { + console.error('TerminalPanel.restore error:', err); + } + } + + _ensureDock() { + if (this.dockEl) return this.dockEl; + const dock = document.createElement('div'); + dock.className = 'terminal-dock'; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'terminal-dock-btn'; + button.title = 'Show Terminal'; + button.setAttribute('aria-label', 'Show Terminal'); + button.innerHTML = ` + + Terminal + `; + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.restore(); + }); + dock.appendChild(button); + document.body.appendChild(dock); + this.dockEl = dock; + this.dockBtnEl = button; + return dock; + } + + _showDock() { + const dock = this._ensureDock(); + if (dock) dock.classList.add('visible'); + } + + _hideDock() { + if (this.dockEl) this.dockEl.classList.remove('visible'); + } + _send(text) { try { if (!this.socket || this.socket.readyState !== 1) { diff --git a/public/styles/main.css b/public/styles/main.css index f05d446..fbf1de7 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3342,13 +3342,19 @@ select.param-input:focus { border: 1px solid var(--border-secondary); color: var(--text-secondary); border-radius: 8px; - padding: 0.35rem 0.6rem; + padding: 0.35rem; cursor: pointer; } .drawer-terminal-btn:hover { background: var(--bg-hover); color: var(--text-primary); } +.drawer-terminal-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + stroke-width: 2; +} .drawer-close { background: transparent; border: 1px solid var(--border-secondary); @@ -3381,7 +3387,6 @@ select.param-input:focus { display: flex; align-items: flex-end; justify-content: center; - padding: 1.5rem; z-index: 1001; pointer-events: none; } @@ -3404,6 +3409,10 @@ select.param-input:focus { flex-direction: column; } +.terminal-panel.minimized { + display: none; +} + .terminal-panel.visible { transform: translateY(0); opacity: 1; @@ -3437,6 +3446,14 @@ select.param-input:focus { background: var(--bg-hover); color: var(--text-primary); } + +.terminal-actions .terminal-minimize-btn { + width: 2rem; + padding: 0.25rem 0; + font-size: 1rem; + line-height: 1; +} + .terminal-body { flex: 1; overflow: auto; @@ -3465,6 +3482,18 @@ select.param-input:focus { border-radius: 6px; padding: 0.4rem 0.6rem; } +.terminal-input-row .terminal-clear-btn { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 6px; + padding: 0.4rem 0.6rem; + cursor: pointer; +} +.terminal-input-row .terminal-clear-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} .terminal-send-btn { background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: white; @@ -3477,6 +3506,62 @@ select.param-input:focus { filter: brightness(1.05); } +.terminal-dock { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + display: none; + pointer-events: none; +} + +.terminal-dock.visible { + display: flex; +} + +.terminal-dock-btn { + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 1rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(21, 31, 46, 0.85); + backdrop-filter: blur(14px); + color: var(--text-primary); + cursor: pointer; + box-shadow: 0 18px 40px rgba(0,0,0,0.35); + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + pointer-events: auto; +} + +.terminal-dock-btn:hover { + transform: translateY(-2px); + box-shadow: 0 22px 44px rgba(0,0,0,0.35); + background: rgba(35, 49, 68, 0.95); +} + +.terminal-dock-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +.terminal-dock-icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + stroke-width: 2; +} + +.terminal-dock-label { + font-size: 0.9rem; + font-weight: 600; +} + /* Topology hover tooltip for labels */ .topology-tooltip { position: fixed;