feat(console): rename to mc-webui, fix change_path, persist transcript

- Rename "meshcli Console" to "mc-webui Console" (modal title + docs).
- Drop redundant "Connected to..." messages; replace intro with a one-line "Type 'help' for available commands." hint.
- Use a teal device-name style so the header label is readable on the dark background.
- Display contact paths with commas (D1,90,05,54) instead of arrows in `contacts` and `path`, matching the standard MeshCore client.
- Fix `change_path`: previously read only args[2] after shlex split, silently writing a 1-byte path. Now joins remaining args, accepts comma/space/continuous-hex, validates hex, auto-deduces hash_size from comma-chunk length (1/2/3-byte hops), and routes through _change_path_async so path_hash_mode is set and the contacts cache is invalidated.
- Update `help` line and add a usage hint for the no-args form.
- Add capped persistent output transcript: GET/POST/DELETE /api/console/output (cap 500 entries). Console restores prior entries (faded) above a divider on open and exposes a trash button to clear it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-05-05 22:18:46 +02:00
parent 58300f4a47
commit 3ef1eac0be
7 changed files with 227 additions and 21 deletions
+3 -3
View File
@@ -2784,15 +2784,15 @@ class DeviceManager:
logger.error(f"discover_path failed: {e}")
return {'success': False, 'error': str(e)}
def change_path(self, name_or_key: str, path: str) -> Dict:
"""Change the path to a contact."""
def change_path(self, name_or_key: str, path: str, hash_size: int = 1) -> Dict:
"""Change the path to a contact. hash_size: 1/2/3 bytes per hop."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
self.execute(self.mc.commands.change_contact_path(contact, path), timeout=10)
self.execute(self._change_path_async(contact, path, hash_size=hash_size), timeout=10)
return {'success': True, 'message': f'Path changed for {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"change_path failed: {e}")
+28 -7
View File
@@ -372,7 +372,6 @@ def handle_chat_disconnect():
def handle_console_connect():
"""Handle console WebSocket connection"""
logger.info("Console WebSocket client connected")
emit('console_status', {'message': 'Connected to mc-webui console'})
@socketio.on('disconnect', namespace='/console')
@@ -494,7 +493,7 @@ def _execute_console_command(args: list) -> str:
meaningful = raw[:hop_count * hash_size * 2]
chunk = hash_size * 2
hops = [meaningful[i:i+chunk].upper() for i in range(0, len(meaningful), chunk)]
path_str = ''.join(hops) if hops else f'len:{opl}'
path_str = ','.join(hops) if hops else f'len:{opl}'
elif opl == 0:
path_str = 'Direct'
else:
@@ -814,7 +813,7 @@ def _execute_console_command(args: list) -> str:
meaningful = raw[:hop_count * hash_size * 2]
chunk = hash_size * 2
hops = [meaningful[i:i+chunk].upper() for i in range(0, len(meaningful), chunk)]
path_str = ''.join(hops) if hops else f'len:{opl}'
path_str = ','.join(hops) if hops else f'len:{opl}'
return f"Path to {name}: {path_str} ({hop_count} hops)"
elif opl == 0:
return f"Path to {name}: Direct"
@@ -845,14 +844,36 @@ def _execute_console_command(args: list) -> str:
elif cmd == 'change_path' and len(args) >= 3:
name = args[1]
path = args[2]
result = device_manager.change_path(name, path)
# Recombine remaining args so space-separated input ("d1 90 05 54") works
raw = ' '.join(args[2:])
if ',' in raw:
chunks = [c.strip() for c in raw.split(',') if c.strip()]
first_len = len(chunks[0]) if chunks else 0
if first_len in (2, 4, 6):
hash_size = first_len // 2
else:
return "Error: hop must be 1, 2, or 3 bytes (2/4/6 hex chars)"
path_hex = ''.join(chunks)
else:
path_hex = raw.replace(' ', '').replace('', '').replace('->', '')
hash_size = 1
try:
bytes.fromhex(path_hex)
except ValueError:
return f"Error: invalid hex in path: {raw}"
if not path_hex or len(path_hex) % (hash_size * 2) != 0:
return f"Error: path length not aligned to {hash_size}-byte hops"
result = device_manager.change_path(name, path_hex, hash_size=hash_size)
if result.get('success'):
return result.get('message', 'OK')
return f"Error: {result.get('error')}"
elif cmd == 'change_path':
return "Usage: change_path <name> <path>\n Path: hex string, e.g. 6a61"
return ("Usage: change_path <name> <hops>\n"
" hops: comma-separated hex, e.g. d1,90,05,54 (1-byte hops)\n"
" 5e34,d1ac (2-byte hops)\n"
" 5e346e,d1ac2c (3-byte hops)\n"
" Spaces or continuous hex also accepted.")
elif cmd == 'advert_path' and len(args) >= 2:
name = ' '.join(args[1:])
@@ -1253,7 +1274,7 @@ def _execute_console_command(args: list) -> str:
" path <name> — Show path to contact\n"
" disc_path <name> — Discover new path\n"
" reset_path <name> — Reset path to flood\n"
" change_path <name> <p> — Change path to contact\n"
" change_path <name> <hops> — Change path (e.g. d1,90,05,54 or 5e34,d1ac)\n"
" advert_path <name> — Get path from advert\n"
" share_contact <name> — Share contact with mesh\n"
" export_contact <name> — Export contact URI\n"
+88
View File
@@ -4869,6 +4869,94 @@ def clear_console_history():
}), 500
# ============================================================
# Console Output History API (persistent transcript)
# ============================================================
CONSOLE_OUTPUT_FILE = 'console_output_history.json'
CONSOLE_OUTPUT_MAX_ENTRIES = 500
CONSOLE_OUTPUT_ALLOWED_TYPES = {'command', 'response', 'error', 'system'}
def _get_console_output_path():
return Path(config.MC_CONFIG_DIR) / CONSOLE_OUTPUT_FILE
def _load_console_output():
path = _get_console_output_path()
try:
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('entries', [])
except Exception as e:
logger.error(f"Error loading console output history: {e}")
return []
def _save_console_output(entries):
path = _get_console_output_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump({'entries': entries}, f, ensure_ascii=False)
return True
except Exception as e:
logger.error(f"Error saving console output history: {e}")
return False
@api_bp.route('/console/output', methods=['GET'])
def get_console_output():
"""Get persisted console output transcript."""
try:
return jsonify({'success': True, 'entries': _load_console_output()}), 200
except Exception as e:
logger.error(f"Error getting console output: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/console/output', methods=['POST'])
def add_console_output():
"""Append an entry to the console output transcript."""
try:
data = request.get_json() or {}
entry_type = data.get('type', 'system')
text = data.get('text', '')
if entry_type not in CONSOLE_OUTPUT_ALLOWED_TYPES:
return jsonify({'success': False, 'error': 'Invalid type'}), 400
if not isinstance(text, str) or not text:
return jsonify({'success': False, 'error': 'Empty text'}), 400
entries = _load_console_output()
entries.append({
'ts': datetime.now().isoformat(timespec='seconds'),
'type': entry_type,
'text': text,
})
if len(entries) > CONSOLE_OUTPUT_MAX_ENTRIES:
entries = entries[-CONSOLE_OUTPUT_MAX_ENTRIES:]
if _save_console_output(entries):
return jsonify({'success': True}), 200
return jsonify({'success': False, 'error': 'Failed to save'}), 500
except Exception as e:
logger.error(f"Error adding console output: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/console/output', methods=['DELETE'])
def clear_console_output():
"""Clear persisted console output transcript."""
try:
if _save_console_output([]):
return jsonify({'success': True, 'message': 'Output cleared'}), 200
return jsonify({'success': False, 'error': 'Failed to clear'}), 500
except Exception as e:
logger.error(f"Error clearing console output: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# Backup Endpoints
# =============================================================================
+85 -4
View File
@@ -14,12 +14,14 @@ let historyIndex = -1;
let pendingCommandDiv = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
console.log('Console page initialized');
loadServerHistory();
await loadOutputHistory();
connectWebSocket();
setupInputHandlers();
setupHistoryDropdown();
setupClearOutputButton();
});
/**
@@ -49,7 +51,6 @@ function connectWebSocket() {
isConnected = true;
updateStatus('connected');
enableInput(true);
addMessage('Connected to meshcli', 'system');
});
socket.on('disconnect', (reason) => {
@@ -57,7 +58,7 @@ function connectWebSocket() {
isConnected = false;
updateStatus('disconnected');
enableInput(false);
addMessage('Disconnected from meshcli', 'error');
addMessage('Disconnected', 'error');
// Clear pending command indicator
if (pendingCommandDiv) {
@@ -200,14 +201,20 @@ function navigateHistory(direction) {
* Add message to console display
* @param {string} text Message text
* @param {string} type Message type: 'command', 'response', 'error', 'system'
* (may include extra modifier classes like 'command pending')
* @param {boolean} persist Whether to save to the persistent transcript (default true)
* @returns {HTMLElement} The created message div
*/
function addMessage(text, type) {
function addMessage(text, type, persist = true) {
const container = document.getElementById('consoleMessages');
const div = document.createElement('div');
div.className = `console-message ${type}`;
div.textContent = text;
container.appendChild(div);
if (persist) {
const baseType = (type || '').split(' ')[0];
saveOutputEntry(baseType, text);
}
return div;
}
@@ -421,3 +428,77 @@ function selectHistoryItem(command) {
historyMenu.classList.remove('show');
}
}
// ============================================================
// Persistent console output transcript
// ============================================================
/**
* Load persisted output entries and render them as historic (faded) items,
* followed by a divider marking the start of the current session.
*/
async function loadOutputHistory() {
try {
const response = await fetch('/api/console/output');
const data = await response.json();
if (!data.success || !Array.isArray(data.entries) || data.entries.length === 0) {
return;
}
const container = document.getElementById('consoleMessages');
if (!container) return;
for (const entry of data.entries) {
const div = document.createElement('div');
div.className = `console-message ${entry.type} historic`;
div.textContent = entry.text;
container.appendChild(div);
}
const divider = document.createElement('hr');
divider.className = 'history-divider';
container.appendChild(divider);
} catch (error) {
console.error('Failed to load output history:', error);
}
}
/**
* POST a single transcript entry to the server (fire-and-forget).
*/
function saveOutputEntry(type, text) {
try {
fetch('/api/console/output', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, text })
}).catch(err => console.error('Failed to persist output entry:', err));
} catch (error) {
console.error('Failed to persist output entry:', error);
}
}
/**
* Clear the persisted transcript and the current display.
*/
async function clearOutputHistory() {
try {
await fetch('/api/console/output', { method: 'DELETE' });
} catch (error) {
console.error('Failed to clear output history:', error);
}
const container = document.getElementById('consoleMessages');
if (container) {
container.innerHTML = '';
}
}
/**
* Wire the trash button next to the history dropdown.
*/
function setupClearOutputButton() {
const btn = document.getElementById('clearOutputBtn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
clearOutputHistory();
});
}
+21 -5
View File
@@ -39,6 +39,11 @@
flex-shrink: 0;
}
.device-name {
color: #4ecdc4;
font-weight: 500;
}
.console-messages {
flex: 1;
overflow-y: auto;
@@ -81,6 +86,16 @@
font-style: italic;
}
.console-message.historic {
opacity: 0.55;
}
.history-divider {
border: none;
border-top: 1px dashed #0f3460;
margin: 0.5rem 0 1rem 0;
}
.console-input-area {
background-color: #16213e;
border-top: 1px solid #0f3460;
@@ -254,7 +269,7 @@
<div class="console-container">
<!-- Header with status indicator -->
<div class="console-header d-flex justify-content-between align-items-center">
<small class="text-muted">{{ device_name }}</small>
<small class="device-name">{{ device_name }}</small>
<div class="d-flex align-items-center gap-2">
<span class="status-dot" id="statusDot"></span>
<small class="text-muted" id="statusText">Connecting...</small>
@@ -263,10 +278,7 @@
<!-- Messages Area -->
<div class="console-messages" id="consoleMessages">
<div class="console-message system">
Type a meshcli command and press Enter.
Examples: infos, contacts, help
</div>
<div class="console-message system">Type 'help' for available commands.</div>
</div>
<!-- Input Area -->
@@ -281,6 +293,10 @@
<div class="history-empty">No commands in history</div>
</div>
</div>
<!-- Clear output transcript -->
<button type="button" class="btn history-btn" id="clearOutputBtn" title="Clear output history">
<i class="bi bi-trash"></i>
</button>
<input type="text"
id="commandInput"
class="form-control console-input"
+1 -1
View File
@@ -192,7 +192,7 @@
<div class="modal-dialog modal-fullscreen">
<div class="modal-content" style="background-color: #1a1a2e;">
<div class="modal-header" style="background-color: #16213e; border-bottom: 1px solid #0f3460;">
<h5 class="modal-title text-white"><i class="bi bi-terminal"></i> meshcli Console</h5>
<h5 class="modal-title text-white"><i class="bi bi-terminal"></i> mc-webui Console</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
+1 -1
View File
@@ -27,7 +27,7 @@ This guide explains how to manage a MeshCore repeater device directly from the m
## 2. Login to Your Repeater and Initialize Conversation
- Open the `meshcli Console`
- Open the `mc-webui Console`
<img src="../images/RPT-Mgmt-05-open-console.png" alt="Console" width="200px">