feat(terminal): add Terminal panel to Node Details with WebSocket #11
Binary file not shown.
|
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 502 KiB |
@@ -177,6 +177,7 @@
|
|||||||
<script src="./scripts/view-models.js"></script>
|
<script src="./scripts/view-models.js"></script>
|
||||||
<!-- Base/leaf components first -->
|
<!-- Base/leaf components first -->
|
||||||
<script src="./scripts/components/DrawerComponent.js"></script>
|
<script src="./scripts/components/DrawerComponent.js"></script>
|
||||||
|
<script src="./scripts/components/TerminalPanelComponent.js"></script>
|
||||||
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
|
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
|
||||||
<script src="./scripts/components/NodeDetailsComponent.js"></script>
|
<script src="./scripts/components/NodeDetailsComponent.js"></script>
|
||||||
<script src="./scripts/components/ClusterMembersComponent.js"></script>
|
<script src="./scripts/components/ClusterMembersComponent.js"></script>
|
||||||
|
|||||||
@@ -29,16 +29,19 @@ class DrawerComponent {
|
|||||||
this.detailsDrawer = document.createElement('div');
|
this.detailsDrawer = document.createElement('div');
|
||||||
this.detailsDrawer.className = 'details-drawer';
|
this.detailsDrawer.className = 'details-drawer';
|
||||||
|
|
||||||
// Header with close button
|
// Header with actions and close button
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'details-drawer-header';
|
header.className = 'details-drawer-header';
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<div class="drawer-title">Node Details</div>
|
<div class="drawer-title">Node Details</div>
|
||||||
<button class="drawer-close" aria-label="Close">
|
<div class="drawer-actions">
|
||||||
|
<button class="drawer-terminal-btn" title="Open Terminal" aria-label="Open Terminal">Terminal</button>
|
||||||
|
<button class="drawer-close" aria-label="Close">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
this.detailsDrawer.appendChild(header);
|
this.detailsDrawer.appendChild(header);
|
||||||
|
|
||||||
@@ -47,11 +50,28 @@ class DrawerComponent {
|
|||||||
this.detailsDrawerContent.className = 'details-drawer-content';
|
this.detailsDrawerContent.className = 'details-drawer-content';
|
||||||
this.detailsDrawer.appendChild(this.detailsDrawerContent);
|
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);
|
document.body.appendChild(this.detailsDrawer);
|
||||||
|
|
||||||
// Close handlers
|
// Close handlers
|
||||||
const close = () => this.closeDrawer();
|
const close = () => this.closeDrawer();
|
||||||
header.querySelector('.drawer-close').addEventListener('click', close);
|
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);
|
this.detailsDrawerBackdrop.addEventListener('click', close);
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') close();
|
if (e.key === 'Escape') close();
|
||||||
@@ -104,6 +124,9 @@ class DrawerComponent {
|
|||||||
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
|
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
|
||||||
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
|
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
|
// Call close callback if provided
|
||||||
if (this.onCloseCallback) {
|
if (this.onCloseCallback) {
|
||||||
this.onCloseCallback();
|
this.onCloseCallback();
|
||||||
|
|||||||
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();
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
@@ -3298,6 +3298,23 @@ select.param-input:focus {
|
|||||||
.details-drawer-header .drawer-title {
|
.details-drawer-header .drawer-title {
|
||||||
font-weight: 600;
|
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 {
|
.drawer-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border-secondary);
|
border: 1px solid var(--border-secondary);
|
||||||
@@ -3323,6 +3340,108 @@ select.param-input:focus {
|
|||||||
.details-drawer-backdrop { display: none; }
|
.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 hover tooltip for labels */
|
||||||
.topology-tooltip {
|
.topology-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user