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>
This commit is contained in:
MarekWo
2026-01-29 13:44:44 +01:00
parent ed8cab6dc5
commit 1ac76f107d
3 changed files with 366 additions and 2 deletions
+132
View File
@@ -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
+153 -2
View File
@@ -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 = '<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');
}
}
+81
View File
@@ -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 @@
<!-- Input Area -->
<div class="console-input-area">
<form id="consoleForm" class="d-flex gap-2">
<!-- History dropdown -->
<div class="history-dropdown">
<button type="button" class="btn history-btn" id="historyBtn" title="Command history" disabled>
<i class="bi bi-clock-history"></i>
</button>
<div class="history-menu" id="historyMenu">
<div class="history-empty">No commands in history</div>
</div>
</div>
<input type="text"
id="commandInput"
class="form-control console-input"