1
0
forked from iarv/mc-webui
Files
mc-webui/app/static/js/console.js
MarekWo 1ac76f107d feat: Add persistent command history to console
- Add server-side API for console history (GET/POST/DELETE)
- Add history dropdown button with clock icon
- Save commands to server after execution
- Load history from server on page load
- History persists between sessions and works across devices
- Max 50 commands stored, duplicates moved to end
- Dropdown shows most recent commands first

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:44:44 +01:00

424 lines
12 KiB
JavaScript

/**
* 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', function() {
console.log('Console page initialized');
loadServerHistory();
connectWebSocket();
setupInputHandlers();
setupHistoryDropdown();
});
/**
* 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: ['websocket', 'polling'],
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);
addMessage('Connected to meshcli', 'system');
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
isConnected = false;
updateStatus('disconnected');
enableInput(false);
addMessage('Disconnected from meshcli', 'error');
// 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');
}
}
/**
* 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'
* @returns {HTMLElement} The created message div
*/
function addMessage(text, type) {
const container = document.getElementById('consoleMessages');
const div = document.createElement('div');
div.className = `console-message ${type}`;
div.textContent = text;
container.appendChild(div);
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 = '<div class="history-empty">No commands in history</div>';
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');
}
}