diff --git a/app/routes/api.py b/app/routes/api.py index 6566121..90fcdba 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -10,6 +10,7 @@ import time import requests from datetime import datetime from io import BytesIO +from pathlib import Path from flask import Blueprint, jsonify, request, send_file from app.meshcore import cli, parser from app.config import config, runtime_config @@ -2761,3 +2762,134 @@ def mark_read_api(): 'success': False, 'error': str(e) }), 500 + + +# ============================================================ +# Console History API +# ============================================================ + +CONSOLE_HISTORY_FILE = 'console_history.json' +CONSOLE_HISTORY_MAX_SIZE = 50 + + +def _get_console_history_path(): + """Get path to console history file""" + return Path(config.MC_CONFIG_DIR) / CONSOLE_HISTORY_FILE + + +def _load_console_history(): + """Load console history from file""" + history_path = _get_console_history_path() + try: + if history_path.exists(): + with open(history_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('commands', []) + except Exception as e: + logger.error(f"Error loading console history: {e}") + return [] + + +def _save_console_history(commands): + """Save console history to file""" + history_path = _get_console_history_path() + try: + # Ensure directory exists + history_path.parent.mkdir(parents=True, exist_ok=True) + with open(history_path, 'w', encoding='utf-8') as f: + json.dump({'commands': commands}, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"Error saving console history: {e}") + return False + + +@api_bp.route('/console/history', methods=['GET']) +def get_console_history(): + """Get console command history""" + try: + commands = _load_console_history() + return jsonify({ + 'success': True, + 'commands': commands + }), 200 + except Exception as e: + logger.error(f"Error getting console history: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/console/history', methods=['POST']) +def add_console_history(): + """Add command to console history""" + try: + data = request.get_json() + if not data or 'command' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing command field' + }), 400 + + command = data['command'].strip() + if not command: + return jsonify({ + 'success': False, + 'error': 'Empty command' + }), 400 + + # Load existing history + commands = _load_console_history() + + # Remove command if already exists (will be moved to end) + if command in commands: + commands.remove(command) + + # Add to end + commands.append(command) + + # Limit size + if len(commands) > CONSOLE_HISTORY_MAX_SIZE: + commands = commands[-CONSOLE_HISTORY_MAX_SIZE:] + + # Save + if _save_console_history(commands): + return jsonify({ + 'success': True, + 'commands': commands + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to save history' + }), 500 + + except Exception as e: + logger.error(f"Error adding console history: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/console/history', methods=['DELETE']) +def clear_console_history(): + """Clear console command history""" + try: + if _save_console_history([]): + return jsonify({ + 'success': True, + 'message': 'History cleared' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to clear history' + }), 500 + except Exception as e: + logger.error(f"Error clearing console history: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/static/js/console.js b/app/static/js/console.js index 8d3596a..b33fef9 100644 --- a/app/static/js/console.js +++ b/app/static/js/console.js @@ -8,15 +8,18 @@ let socket = null; let isConnected = false; -let commandHistory = []; +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(); }); /** @@ -138,7 +141,7 @@ function sendCommand() { return; } - // Add to history (avoid duplicates at end) + // Add to local history (avoid duplicates at end) if (commandHistory.length === 0 || commandHistory[commandHistory.length - 1] !== command) { commandHistory.push(command); // Limit history size @@ -148,6 +151,9 @@ function sendCommand() { } 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'); @@ -251,6 +257,7 @@ function updateStatus(status) { function enableInput(enabled) { const input = document.getElementById('commandInput'); const btn = document.getElementById('sendBtn'); + const historyBtn = document.getElementById('historyBtn'); if (input) { input.disabled = !enabled; @@ -262,6 +269,10 @@ function enableInput(enabled) { if (btn) { btn.disabled = !enabled; } + + if (historyBtn) { + historyBtn.disabled = !enabled; + } } // Cleanup on page unload @@ -270,3 +281,143 @@ window.addEventListener('beforeunload', () => { 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'); + } +} diff --git a/app/templates/console.html b/app/templates/console.html index b6d54b6..05ebf56 100644 --- a/app/templates/console.html +++ b/app/templates/console.html @@ -154,6 +154,78 @@ to { transform: rotate(360deg); } } + /* History dropdown */ + .history-dropdown { + position: relative; + } + + .history-btn { + background-color: #0f3460; + border: 1px solid #1a1a2e; + color: #4ecdc4; + } + + .history-btn:hover, .history-btn:focus { + background-color: #1a1a4e; + border-color: #4ecdc4; + color: #4ecdc4; + } + + .history-btn:disabled { + background-color: #0a1628; + color: #444; + } + + .history-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + min-width: 250px; + max-width: 100%; + max-height: 300px; + overflow-y: auto; + background-color: #16213e; + border: 1px solid #0f3460; + border-radius: 0.375rem; + margin-bottom: 0.25rem; + display: none; + z-index: 1000; + } + + .history-menu.show { + display: block; + } + + .history-item { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + color: #e0e0e0; + text-decoration: none; + font-family: 'Courier New', Consolas, monospace; + font-size: 0.85rem; + border: none; + background: none; + text-align: left; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .history-item:hover { + background-color: #0f3460; + color: #00ff88; + } + + .history-empty { + padding: 0.75rem; + color: #666; + text-align: center; + font-style: italic; + } + /* Mobile adjustments */ @media (max-width: 576px) { .console-header { @@ -200,6 +272,15 @@
+ +
+ +
+
No commands in history
+
+