/** * mc-webui Console - Chat-style meshcli interface * * Provides interactive command console for meshcli via WebSocket. * Commands are sent to meshcore-bridge and responses are displayed * in a chat-like format. */ let socket = null; let isConnected = false; let commandHistory = []; // Local session history (for arrow keys) let serverHistory = []; // Server-persisted history (for dropdown) let historyIndex = -1; let pendingCommandDiv = null; // Initialize on page load document.addEventListener('DOMContentLoaded', async function() { console.log('Console page initialized'); loadServerHistory(); await loadOutputHistory(); connectWebSocket(); setupInputHandlers(); setupHistoryDropdown(); setupClearOutputButton(); setupScrollToBottom(); }); /** * Connect to WebSocket server (proxied through main Flask app) */ function connectWebSocket() { updateStatus('connecting'); // Connect to same origin - WebSocket is proxied through main Flask app const wsUrl = window.location.origin; console.log('Connecting to WebSocket:', wsUrl); try { socket = io(wsUrl + '/console', { transports: ['polling'], upgrade: false, reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 20000 }); // Connection events socket.on('connect', () => { console.log('WebSocket connected'); isConnected = true; updateStatus('connected'); enableInput(true); }); socket.on('disconnect', (reason) => { console.log('WebSocket disconnected:', reason); isConnected = false; updateStatus('disconnected'); enableInput(false); // Transient session event — show inline but don't persist to transcript addMessage('Disconnected', 'error', false); // Clear pending command indicator if (pendingCommandDiv) { pendingCommandDiv.classList.remove('pending'); pendingCommandDiv = null; } }); socket.on('connect_error', (error) => { console.error('WebSocket connection error:', error); updateStatus('disconnected'); }); // Console events from server socket.on('console_status', (data) => { console.log('Console status:', data); if (data.message) { addMessage(data.message, 'system'); } }); socket.on('command_response', (data) => { console.log('Command response:', data); // Clear pending indicator if (pendingCommandDiv) { pendingCommandDiv.classList.remove('pending'); pendingCommandDiv = null; } // Display response if (data.success) { const output = data.output || '(no output)'; addMessage(output, 'response'); } else { addMessage(`Error: ${data.error}`, 'error'); } scrollToBottom(); }); } catch (error) { console.error('Failed to create WebSocket connection:', error); updateStatus('disconnected'); addMessage('Failed to connect: ' + error.message, 'error', false); } } /** * Setup input form handlers */ function setupInputHandlers() { const form = document.getElementById('consoleForm'); const input = document.getElementById('commandInput'); // Form submit form.addEventListener('submit', (e) => { e.preventDefault(); sendCommand(); }); // Command history navigation with arrow keys input.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp') { e.preventDefault(); navigateHistory(-1); } else if (e.key === 'ArrowDown') { e.preventDefault(); navigateHistory(1); } }); } /** * Send command to meshcli */ function sendCommand() { const input = document.getElementById('commandInput'); const command = input.value.trim(); if (!command || !isConnected) { return; } // Add to local history (avoid duplicates at end) if (commandHistory.length === 0 || commandHistory[commandHistory.length - 1] !== command) { commandHistory.push(command); // Limit history size if (commandHistory.length > 100) { commandHistory.shift(); } } historyIndex = commandHistory.length; // Save to server history (async, don't wait) saveToServerHistory(command); // Show command in chat with pending indicator pendingCommandDiv = addMessage(command, 'command pending'); // Send to server socket.emit('send_command', { command: command }); // Clear input input.value = ''; scrollToBottom(); } /** * Navigate command history * @param {number} direction -1 for older, 1 for newer */ function navigateHistory(direction) { const input = document.getElementById('commandInput'); if (commandHistory.length === 0) { return; } historyIndex += direction; // Clamp to valid range if (historyIndex < 0) { historyIndex = 0; } if (historyIndex >= commandHistory.length) { historyIndex = commandHistory.length; input.value = ''; return; } input.value = commandHistory[historyIndex]; // Move cursor to end setTimeout(() => { input.selectionStart = input.selectionEnd = input.value.length; }, 0); } /** * Add message to console display * @param {string} text Message text * @param {string} type Message type: 'command', 'response', 'error', 'system' * (may include extra modifier classes like 'command pending') * @param {boolean} persist Whether to save to the persistent transcript (default true) * @returns {HTMLElement} The created message div */ function addMessage(text, type, persist = true) { const container = document.getElementById('consoleMessages'); const div = document.createElement('div'); div.className = `console-message ${type}`; div.textContent = text; container.appendChild(div); if (persist) { const baseType = (type || '').split(' ')[0]; saveOutputEntry(baseType, text); } return div; } /** * Scroll messages container to bottom */ function scrollToBottom() { const container = document.getElementById('consoleMessages'); // Use setTimeout to ensure DOM is updated setTimeout(() => { container.scrollTop = container.scrollHeight; }, 10); } /** * Update connection status indicator * @param {string} status 'connected', 'disconnected', or 'connecting' */ function updateStatus(status) { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); if (!dot || !text) return; dot.className = `status-dot ${status}`; switch (status) { case 'connected': text.textContent = 'Connected'; text.className = 'text-success'; break; case 'disconnected': text.textContent = 'Disconnected'; text.className = 'text-danger'; break; case 'connecting': text.textContent = 'Connecting...'; text.className = 'text-warning'; break; } } /** * Enable or disable input controls * @param {boolean} enabled */ function enableInput(enabled) { const input = document.getElementById('commandInput'); const btn = document.getElementById('sendBtn'); const historyBtn = document.getElementById('historyBtn'); if (input) { input.disabled = !enabled; if (enabled) { input.focus(); } } if (btn) { btn.disabled = !enabled; } if (historyBtn) { historyBtn.disabled = !enabled; } } // Cleanup on page unload window.addEventListener('beforeunload', () => { if (socket) { socket.disconnect(); } }); // ============================================================ // Server-side command history // ============================================================ /** * Load command history from server */ async function loadServerHistory() { try { const response = await fetch('/api/console/history'); const data = await response.json(); if (data.success && data.commands) { serverHistory = data.commands; // Also populate local history for arrow key navigation commandHistory = [...serverHistory]; historyIndex = commandHistory.length; console.log(`Loaded ${serverHistory.length} commands from server history`); } } catch (error) { console.error('Failed to load server history:', error); } } /** * Save command to server history * @param {string} command Command to save */ async function saveToServerHistory(command) { try { const response = await fetch('/api/console/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: command }) }); const data = await response.json(); if (data.success && data.commands) { serverHistory = data.commands; } } catch (error) { console.error('Failed to save to server history:', error); } } /** * Setup history dropdown button and menu */ function setupHistoryDropdown() { const historyBtn = document.getElementById('historyBtn'); const historyMenu = document.getElementById('historyMenu'); if (!historyBtn || !historyMenu) return; // Toggle dropdown on button click historyBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleHistoryDropdown(); }); // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (!historyMenu.contains(e.target) && e.target !== historyBtn) { historyMenu.classList.remove('show'); } }); // Close dropdown on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { historyMenu.classList.remove('show'); } }); } /** * Toggle history dropdown visibility */ function toggleHistoryDropdown() { const historyMenu = document.getElementById('historyMenu'); if (!historyMenu) return; if (historyMenu.classList.contains('show')) { historyMenu.classList.remove('show'); } else { populateHistoryDropdown(); historyMenu.classList.add('show'); } } /** * Populate history dropdown with commands */ function populateHistoryDropdown() { const historyMenu = document.getElementById('historyMenu'); if (!historyMenu) return; historyMenu.innerHTML = ''; if (serverHistory.length === 0) { historyMenu.innerHTML = '
No commands in history
'; return; } // Show most recent first (reversed) const reversedHistory = [...serverHistory].reverse(); reversedHistory.forEach((cmd) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'history-item'; item.textContent = cmd; item.title = cmd; item.addEventListener('click', () => selectHistoryItem(cmd)); historyMenu.appendChild(item); }); } /** * Select a command from history dropdown * @param {string} command Command to select */ function selectHistoryItem(command) { const input = document.getElementById('commandInput'); const historyMenu = document.getElementById('historyMenu'); if (input) { input.value = command; input.focus(); // Move cursor to end setTimeout(() => { input.selectionStart = input.selectionEnd = input.value.length; }, 0); } if (historyMenu) { historyMenu.classList.remove('show'); } } // ============================================================ // Persistent console output transcript // ============================================================ /** * Load persisted output entries and render them as historic (faded) items, * followed by a divider marking the start of the current session. */ async function loadOutputHistory() { try { const response = await fetch('/api/console/output'); const data = await response.json(); if (!data.success || !Array.isArray(data.entries) || data.entries.length === 0) { return; } const container = document.getElementById('consoleMessages'); if (!container) return; for (const entry of data.entries) { const div = document.createElement('div'); div.className = `console-message ${entry.type} historic`; div.textContent = entry.text; container.appendChild(div); } const divider = document.createElement('hr'); divider.className = 'history-divider'; container.appendChild(divider); // Open at the bottom of the transcript, like a chat window scrollToBottom(); } catch (error) { console.error('Failed to load output history:', error); } } /** * POST a single transcript entry to the server (fire-and-forget). */ function saveOutputEntry(type, text) { try { fetch('/api/console/output', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, text }) }).catch(err => console.error('Failed to persist output entry:', err)); } catch (error) { console.error('Failed to persist output entry:', error); } } /** * Clear the persisted transcript and the current display. */ async function clearOutputHistory() { try { await fetch('/api/console/output', { method: 'DELETE' }); } catch (error) { console.error('Failed to clear output history:', error); } const container = document.getElementById('consoleMessages'); if (container) { container.innerHTML = ''; } } /** * Wire the trash button next to the history dropdown. */ function setupClearOutputButton() { const btn = document.getElementById('clearOutputBtn'); if (!btn) return; btn.addEventListener('click', (e) => { e.preventDefault(); clearOutputHistory(); }); } /** * Wire the floating scroll-to-bottom button: visible when the user has * scrolled away from the bottom of the transcript. Also auto-scrolls to * the bottom whenever the container transitions from hidden (0 height, * e.g. inside a closed Bootstrap modal) to visible. */ function setupScrollToBottom() { const container = document.getElementById('consoleMessages'); const btn = document.getElementById('scrollBottomBtn'); if (!container || !btn) return; const SHOW_THRESHOLD = 80; // px from bottom before button appears const update = () => { const distance = container.scrollHeight - container.scrollTop - container.clientHeight; btn.classList.toggle('show', distance > SHOW_THRESHOLD); }; container.addEventListener('scroll', update, { passive: true }); // Re-check after content changes (new messages, dropdowns, etc.) new MutationObserver(update).observe(container, { childList: true, subtree: false }); btn.addEventListener('click', (e) => { e.preventDefault(); container.scrollTop = container.scrollHeight; }); // When the modal hosting this iframe opens, the container goes from // 0 height to its real height. Jump to the latest entry on that edge. let wasVisible = container.clientHeight > 0; const ro = new ResizeObserver(() => { const isVisible = container.clientHeight > 0; if (isVisible && !wasVisible) { container.scrollTop = container.scrollHeight; } wasVisible = isVisible; update(); }); ro.observe(container); }