mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-06 08:43:17 +02:00
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:
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user