1
0
forked from iarv/mc-webui

feat: Add interactive meshcli console with WebSocket support

- Add Flask-SocketIO backend with gevent for real-time communication
- Create chat-style console UI showing only user's command responses
- WebSocket commands tracked separately from HTTP API (ws_ prefix)
- Console accessible from main menu as fullscreen modal
- Command history navigation with arrow keys
- Auto-reconnection on disconnect
- Update service worker cache for offline support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-09 08:27:42 +01:00
parent 783a00c798
commit a5f7fd59e6
10 changed files with 719 additions and 7 deletions

View File

@@ -72,6 +72,26 @@ def contact_existing_list():
)
@views_bp.route('/console')
def console():
"""
Interactive meshcli console - chat-style command interface.
Connects via WebSocket to meshcore-bridge for real-time command execution.
"""
# Build WebSocket URL for meshcore-bridge
# Browser connects directly to bridge on port 5001
# Use the same hostname the user is accessing but with port 5001
host = request.host.split(':')[0] # Get hostname without port
bridge_ws_url = f"http://{host}:5001"
return render_template(
'console.html',
device_name=config.MC_DEVICE_NAME,
bridge_ws_url=bridge_ws_url
)
@views_bp.route('/health')
def health():
"""

274
app/static/js/console.js Normal file
View File

@@ -0,0 +1,274 @@
/**
* mc-webui Console - Chat-style meshcli interface
*
* Provides interactive command console for meshcli via WebSocket.
* Commands are sent to meshcore-bridge and responses are displayed
* in a chat-like format.
*/
let socket = null;
let isConnected = false;
let commandHistory = [];
let historyIndex = -1;
let pendingCommandDiv = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('Console page initialized');
connectWebSocket();
setupInputHandlers();
});
/**
* Connect to WebSocket server on meshcore-bridge
*/
function connectWebSocket() {
updateStatus('connecting');
// Get WebSocket URL - bridge runs on port 5001
// Use same hostname as current page but different port
const bridgeUrl = window.MC_CONFIG?.bridgeWsUrl ||
`${window.location.protocol}//${window.location.hostname}:5001`;
console.log('Connecting to WebSocket:', bridgeUrl);
try {
socket = io(bridgeUrl + '/console', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 20000
});
// Connection events
socket.on('connect', () => {
console.log('WebSocket connected');
isConnected = true;
updateStatus('connected');
enableInput(true);
addMessage('Connected to meshcli', 'system');
});
socket.on('disconnect', (reason) => {
console.log('WebSocket disconnected:', reason);
isConnected = false;
updateStatus('disconnected');
enableInput(false);
addMessage('Disconnected from meshcli', 'error');
// Clear pending command indicator
if (pendingCommandDiv) {
pendingCommandDiv.classList.remove('pending');
pendingCommandDiv = null;
}
});
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
updateStatus('disconnected');
});
// Console events from server
socket.on('console_status', (data) => {
console.log('Console status:', data);
if (data.message) {
addMessage(data.message, 'system');
}
});
socket.on('command_response', (data) => {
console.log('Command response:', data);
// Clear pending indicator
if (pendingCommandDiv) {
pendingCommandDiv.classList.remove('pending');
pendingCommandDiv = null;
}
// Display response
if (data.success) {
const output = data.output || '(no output)';
addMessage(output, 'response');
} else {
addMessage(`Error: ${data.error}`, 'error');
}
scrollToBottom();
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
updateStatus('disconnected');
addMessage('Failed to connect: ' + error.message, 'error');
}
}
/**
* Setup input form handlers
*/
function setupInputHandlers() {
const form = document.getElementById('consoleForm');
const input = document.getElementById('commandInput');
// Form submit
form.addEventListener('submit', (e) => {
e.preventDefault();
sendCommand();
});
// Command history navigation with arrow keys
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateHistory(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateHistory(1);
}
});
}
/**
* Send command to meshcli
*/
function sendCommand() {
const input = document.getElementById('commandInput');
const command = input.value.trim();
if (!command || !isConnected) {
return;
}
// Add to history (avoid duplicates at end)
if (commandHistory.length === 0 || commandHistory[commandHistory.length - 1] !== command) {
commandHistory.push(command);
// Limit history size
if (commandHistory.length > 100) {
commandHistory.shift();
}
}
historyIndex = commandHistory.length;
// Show command in chat with pending indicator
pendingCommandDiv = addMessage(command, 'command pending');
// Send to server
socket.emit('send_command', { command: command });
// Clear input
input.value = '';
scrollToBottom();
}
/**
* Navigate command history
* @param {number} direction -1 for older, 1 for newer
*/
function navigateHistory(direction) {
const input = document.getElementById('commandInput');
if (commandHistory.length === 0) {
return;
}
historyIndex += direction;
// Clamp to valid range
if (historyIndex < 0) {
historyIndex = 0;
}
if (historyIndex >= commandHistory.length) {
historyIndex = commandHistory.length;
input.value = '';
return;
}
input.value = commandHistory[historyIndex];
// Move cursor to end
setTimeout(() => {
input.selectionStart = input.selectionEnd = input.value.length;
}, 0);
}
/**
* Add message to console display
* @param {string} text Message text
* @param {string} type Message type: 'command', 'response', 'error', 'system'
* @returns {HTMLElement} The created message div
*/
function addMessage(text, type) {
const container = document.getElementById('consoleMessages');
const div = document.createElement('div');
div.className = `console-message ${type}`;
div.textContent = text;
container.appendChild(div);
return div;
}
/**
* Scroll messages container to bottom
*/
function scrollToBottom() {
const container = document.getElementById('consoleMessages');
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 10);
}
/**
* Update connection status indicator
* @param {string} status 'connected', 'disconnected', or 'connecting'
*/
function updateStatus(status) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (!dot || !text) return;
dot.className = `status-dot ${status}`;
switch (status) {
case 'connected':
text.textContent = 'Connected';
text.className = 'text-success';
break;
case 'disconnected':
text.textContent = 'Disconnected';
text.className = 'text-danger';
break;
case 'connecting':
text.textContent = 'Connecting...';
text.className = 'text-warning';
break;
}
}
/**
* Enable or disable input controls
* @param {boolean} enabled
*/
function enableInput(enabled) {
const input = document.getElementById('commandInput');
const btn = document.getElementById('sendBtn');
if (input) {
input.disabled = !enabled;
if (enabled) {
input.focus();
}
}
if (btn) {
btn.disabled = !enabled;
}
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (socket) {
socket.disconnect();
}
});

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'mc-webui-v3';
const CACHE_NAME = 'mc-webui-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/css/style.css',
@@ -6,6 +6,7 @@ const ASSETS_TO_CACHE = [
'/static/js/dm.js',
'/static/js/contacts.js',
'/static/js/message-utils.js',
'/static/js/console.js',
'/static/images/android-chrome-192x192.png',
'/static/images/android-chrome-512x512.png',
// Bootstrap 5.3.2 (local)
@@ -19,7 +20,11 @@ const ASSETS_TO_CACHE = [
'/static/vendor/emoji-picker-element/index.js',
'/static/vendor/emoji-picker-element/picker.js',
'/static/vendor/emoji-picker-element/database.js',
'/static/vendor/emoji-picker-element-data/en/emojibase/data.json'
'/static/vendor/emoji-picker-element-data/en/emojibase/data.json',
// Socket.IO client 4.x (local)
'/static/vendor/socket.io/socket.io.min.js',
// Console page
'/console'
];
// Install event - cache core assets

7
app/static/vendor/socket.io/socket.io.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -109,6 +109,13 @@
<div class="list-group-item py-2 mt-2">
<small class="text-muted fw-bold text-uppercase">Configuration</small>
</div>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#consoleModal" data-bs-dismiss="offcanvas">
<i class="bi bi-terminal" style="font-size: 1.5rem;"></i>
<div>
<span>Console</span>
<small class="d-block text-muted">Direct meshcli commands</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#settingsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-gear" style="font-size: 1.5rem;"></i>
<span>Settings</span>

238
app/templates/console.html Normal file
View File

@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Console - mc-webui</title>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
html, body {
height: 100%;
margin: 0;
background-color: #1a1a2e;
}
.console-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.console-header {
background-color: #16213e;
border-bottom: 1px solid #0f3460;
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.console-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: #1a1a2e;
min-height: 0;
}
.console-message {
margin-bottom: 1rem;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.9rem;
}
.console-message.command {
color: #00ff88;
}
.console-message.command::before {
content: '> ';
color: #888;
}
.console-message.response {
color: #e0e0e0;
white-space: pre-wrap;
word-break: break-word;
background-color: #16213e;
padding: 0.5rem;
border-radius: 0.25rem;
border-left: 3px solid #0f3460;
}
.console-message.error {
color: #ff6b6b;
}
.console-message.system {
color: #4ecdc4;
font-style: italic;
}
.console-input-area {
background-color: #16213e;
border-top: 1px solid #0f3460;
padding: 0.75rem;
flex-shrink: 0;
}
.console-input {
background-color: #0f3460;
border: 1px solid #1a1a2e;
color: #00ff88;
font-family: 'Courier New', Consolas, monospace;
}
.console-input:focus {
background-color: #0f3460;
border-color: #00ff88;
color: #00ff88;
box-shadow: 0 0 0 0.2rem rgba(0, 255, 136, 0.25);
}
.console-input::placeholder {
color: #666;
}
.console-input:disabled {
background-color: #0a1628;
color: #444;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-dot.connected {
background-color: #00ff88;
}
.status-dot.disconnected {
background-color: #ff6b6b;
}
.status-dot.connecting {
background-color: #ffd93d;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Loading spinner for pending commands */
.console-message.pending::after {
content: '';
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #4ecdc4;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mobile adjustments */
@media (max-width: 576px) {
.console-header {
padding: 0.5rem 0.75rem;
}
.console-header h6 {
font-size: 0.9rem;
}
.console-messages {
padding: 0.5rem;
}
.console-message {
font-size: 0.8rem;
}
.console-input-area {
padding: 0.5rem;
}
}
</style>
</head>
<body>
<div class="console-container">
<!-- Header -->
<div class="console-header d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 text-white">
<i class="bi bi-terminal"></i> meshcli Console
</h6>
<small class="text-muted">{{ device_name }}</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="status-dot" id="statusDot"></span>
<small class="text-muted" id="statusText">Connecting...</small>
</div>
</div>
<!-- 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>
<!-- Input Area -->
<div class="console-input-area">
<form id="consoleForm" class="d-flex gap-2">
<input type="text"
id="commandInput"
class="form-control console-input"
placeholder="Enter command..."
autocomplete="off"
autocapitalize="off"
spellcheck="false"
disabled>
<button type="submit" class="btn btn-success" id="sendBtn" disabled>
<i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<!-- Socket.IO client -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Console JS -->
<script src="{{ url_for('static', filename='js/console.js') }}"></script>
<script>
// Pass configuration from Flask to JavaScript
window.MC_CONFIG = {
bridgeWsUrl: "{{ bridge_ws_url }}",
deviceName: "{{ device_name }}"
};
</script>
</body>
</html>

View File

@@ -43,7 +43,8 @@
/* Modal fullscreen - remove all margins and padding */
#dmModal .modal-dialog.modal-fullscreen,
#contactsModal .modal-dialog.modal-fullscreen {
#contactsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
@@ -52,14 +53,16 @@
}
#dmModal .modal-content,
#contactsModal .modal-content {
#contactsModal .modal-content,
#consoleModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
}
#dmModal .modal-body,
#contactsModal .modal-body {
#contactsModal .modal-body,
#consoleModal .modal-body {
overflow: hidden !important;
}
</style>
@@ -171,6 +174,23 @@
</div>
</div>
</div>
<!-- Console Modal (Full Screen) -->
<div class="modal fade" id="consoleModal" tabindex="-1" aria-hidden="true">
<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>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
</div>
<div class="modal-body p-0">
<iframe id="consoleFrame" src="/console" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
@@ -232,6 +252,17 @@
}
});
}
// Console modal - reload iframe when opened to reset WebSocket connection
const consoleModal = document.getElementById('consoleModal');
if (consoleModal) {
consoleModal.addEventListener('show.bs.modal', function () {
const consoleFrame = document.getElementById('consoleFrame');
if (consoleFrame) {
consoleFrame.src = '/console';
}
});
}
});
</script>
{% endblock %}

View File

@@ -8,6 +8,8 @@ services:
restart: unless-stopped
devices:
- "${MC_SERIAL_PORT}:${MC_SERIAL_PORT}"
ports:
- "5001:5001" # Expose for WebSocket console access
volumes:
- "${MC_CONFIG_DIR}:/root/.config/meshcore:rw"
environment:

View File

@@ -18,8 +18,10 @@ import time
import json
import queue
import uuid
import shlex
from pathlib import Path
from flask import Flask, request, jsonify
from flask_socketio import SocketIO, emit
logging.basicConfig(
level=logging.INFO,
@@ -29,6 +31,9 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
# Initialize SocketIO with gevent for async support
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='gevent')
# Configuration
MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0')
MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/config')
@@ -275,6 +280,18 @@ class MeshCLISession:
logger.info(f"Command [{cmd_id}] completed (timeout-based)")
response_dict["done"] = True
event.set()
# If this is a WebSocket command, emit response to that client
if cmd_id.startswith("ws_"):
socket_id = response_dict.get("socket_id")
if socket_id:
output = '\n'.join(response_dict.get("response", []))
socketio.emit('command_response', {
'success': True,
'output': output,
'cmd_id': cmd_id
}, room=socket_id, namespace='/console')
if self.current_cmd_id == cmd_id:
self.current_cmd_id = None
return
@@ -429,6 +446,73 @@ class MeshCLISession:
'returncode': 0
}
def execute_ws_command(self, command_text, socket_id, timeout=DEFAULT_TIMEOUT):
"""
Execute a CLI command from WebSocket client.
The response will be emitted via socketio.emit in _monitor_response_timeout.
Args:
command_text: Raw command string from user
socket_id: WebSocket session ID for response routing
timeout: Max time to wait for response
Returns:
Dict with success status (response already emitted via WebSocket)
"""
cmd_id = f"ws_{uuid.uuid4().hex[:8]}"
# Parse command into args (respects quotes)
try:
args = shlex.split(command_text)
except ValueError:
args = command_text.split()
# Build command line - use double quotes for args with spaces/special chars
quoted_args = []
for arg in args:
if ' ' in arg or '"' in arg or "'" in arg:
escaped = arg.replace('"', '\\"')
quoted_args.append(f'"{escaped}"')
else:
quoted_args.append(arg)
command = ' '.join(quoted_args)
event = threading.Event()
response_dict = {
"event": event,
"response": [],
"done": False,
"error": None,
"last_line_time": time.time(),
"socket_id": socket_id # Track which WebSocket client sent this
}
# Queue command
self.command_queue.put((cmd_id, command, event, response_dict))
logger.info(f"WebSocket command [{cmd_id}] queued: {command}")
# Wait for completion
if not event.wait(timeout):
logger.error(f"WebSocket command [{cmd_id}] timeout after {timeout}s")
# Cleanup
with self.pending_lock:
if cmd_id in self.pending_commands:
del self.pending_commands[cmd_id]
# Emit error to client
socketio.emit('command_response', {
'success': False,
'error': f'Command timeout after {timeout} seconds',
'cmd_id': cmd_id
}, room=socket_id, namespace='/console')
return {'success': False, 'error': f'Command timeout after {timeout}s'}
# Response already emitted in _monitor_response_timeout
return {'success': True}
def shutdown(self):
"""Gracefully shutdown session"""
logger.info("Shutting down meshcli session")
@@ -789,6 +873,43 @@ def set_manual_add_contacts():
}), 500
# =============================================================================
# WebSocket handlers for console
# =============================================================================
@socketio.on('connect', namespace='/console')
def console_connect():
"""Handle console client connection"""
logger.info(f"Console client connected: {request.sid}")
emit('console_status', {'status': 'connected', 'message': 'Connected to meshcli'})
@socketio.on('disconnect', namespace='/console')
def console_disconnect():
"""Handle console client disconnection"""
logger.info(f"Console client disconnected: {request.sid}")
@socketio.on('send_command', namespace='/console')
def handle_console_command(data):
"""Handle command from console client"""
if not meshcli_session or not meshcli_session.process:
emit('command_response', {'success': False, 'error': 'meshcli session not available'})
return
command_text = data.get('command', '').strip()
if not command_text:
return
logger.info(f"Console command from {request.sid}: {command_text}")
# Execute command asynchronously using socketio background task
def execute_async():
meshcli_session.execute_ws_command(command_text, request.sid)
socketio.start_background_task(execute_async)
if __name__ == '__main__':
logger.info(f"Starting MeshCore Bridge on port 5001")
logger.info(f"Serial port: {MC_SERIAL_PORT}")
@@ -807,5 +928,5 @@ if __name__ == '__main__':
logger.error(f"Failed to initialize meshcli session: {e}")
logger.error("Bridge will start but /cli endpoint will be unavailable")
# Run on all interfaces to allow Docker network access
app.run(host='0.0.0.0', port=5001, debug=False)
# Run with SocketIO (supports WebSocket) on all interfaces
socketio.run(app, host='0.0.0.0', port=5001, debug=False)

View File

@@ -1,3 +1,10 @@
# MeshCore Bridge - Minimal dependencies
Flask==3.0.0
Werkzeug==3.0.1
# WebSocket support for console
flask-socketio==5.3.6
python-socketio==5.10.0
python-engineio==4.8.1
gevent==23.9.1
gevent-websocket==0.10.1