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:
@@ -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
274
app/static/js/console.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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
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
@@ -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
238
app/templates/console.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user