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 = '