mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-04 16:03:07 +02:00
fd054a95bd
Three tabs open made every endpoint answer in ~20s, which two sessions read as contention on a shared server-side lock. It wasn't the server. With 3 tabs open, /health - which touches neither the database nor the device - measured a 14.7s median from inside a tab and 11ms from a client outside the browser at the same instant. The server was idle throughout. Socket.IO clients pinned transports: ['polling'], upgrade: false, so each tab held one HTTP connection open for its whole lifetime. Browsers allow six concurrent HTTP/1.1 connections per origin, shared across every tab, so three tabs consumed the pool and everything else queued in the browser waiting for a free connection. /proc/net/tcp in the container confirmed it: pinned at exactly 6 established connections, unmoving. The pin dates from1d47c9c, when werkzeug had no WebSocket support and every upgrade attempt returned HTTP 500. python-engineio==4.8.1 (pinned five weeks later, ind3590f9) pulls in simple-websocket, which fixed that; the workaround had outlived its premise. Drop it and use the default transports, which open on polling and upgrade. A WebSocket is not part of the HTTP pool, so the pool is released. Where the upgrade is blocked - a proxy that drops the Upgrade header - the client stays on polling by itself, which is exactly today's behaviour. Measured with 3 tabs, in-page medians: /health 14664ms -> 12ms, /api/status 19282ms -> 64ms, both now matching what the same probes read from outside the browser. All three tabs report transport "websocket", server pushes still arrive over it, and the log no longer fills with "Session is disconnected" (0 occurrences, 0 tracebacks across the run). Also corrects the earlier diagnosis in the docs: the per-endpoint timings that looked like a lock were measured request->requestfinished in the browser, which includes connection-queue time, so every endpoint flattened to the same figure regardless of its own cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
549 lines
16 KiB
JavaScript
549 lines
16 KiB
JavaScript
/**
|
|
* 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 = []; // 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', async function() {
|
|
console.log('Console page initialized');
|
|
loadServerHistory();
|
|
await loadOutputHistory();
|
|
connectWebSocket();
|
|
setupInputHandlers();
|
|
setupHistoryDropdown();
|
|
setupClearOutputButton();
|
|
setupScrollToBottom();
|
|
});
|
|
|
|
/**
|
|
* Connect to WebSocket server (proxied through main Flask app)
|
|
*/
|
|
function connectWebSocket() {
|
|
updateStatus('connecting');
|
|
|
|
// Connect to same origin - WebSocket is proxied through main Flask app
|
|
const wsUrl = window.location.origin;
|
|
|
|
console.log('Connecting to WebSocket:', wsUrl);
|
|
|
|
try {
|
|
// Default transports — see the note in app.js connectChatSocket().
|
|
socket = io(wsUrl + '/console', {
|
|
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);
|
|
});
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
console.log('WebSocket disconnected:', reason);
|
|
isConnected = false;
|
|
updateStatus('disconnected');
|
|
enableInput(false);
|
|
// Transient session event — show inline but don't persist to transcript
|
|
addMessage('Disconnected', 'error', false);
|
|
|
|
// 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', false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 local 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;
|
|
|
|
// Save to server history (async, don't wait)
|
|
saveToServerHistory(command);
|
|
|
|
// 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'
|
|
* (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, 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;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
const historyBtn = document.getElementById('historyBtn');
|
|
|
|
if (input) {
|
|
input.disabled = !enabled;
|
|
if (enabled) {
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
if (btn) {
|
|
btn.disabled = !enabled;
|
|
}
|
|
|
|
if (historyBtn) {
|
|
historyBtn.disabled = !enabled;
|
|
}
|
|
}
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener('beforeunload', () => {
|
|
if (socket) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 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);
|
|
// Open at the bottom of the transcript, like a chat window
|
|
scrollToBottom();
|
|
} 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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wire the floating scroll-to-bottom button: visible when the user has
|
|
* scrolled away from the bottom of the transcript. Also auto-scrolls to
|
|
* the bottom whenever the container transitions from hidden (0 height,
|
|
* e.g. inside a closed Bootstrap modal) to visible.
|
|
*/
|
|
function setupScrollToBottom() {
|
|
const container = document.getElementById('consoleMessages');
|
|
const btn = document.getElementById('scrollBottomBtn');
|
|
if (!container || !btn) return;
|
|
|
|
const SHOW_THRESHOLD = 80; // px from bottom before button appears
|
|
|
|
const update = () => {
|
|
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
btn.classList.toggle('show', distance > SHOW_THRESHOLD);
|
|
};
|
|
|
|
container.addEventListener('scroll', update, { passive: true });
|
|
// Re-check after content changes (new messages, dropdowns, etc.)
|
|
new MutationObserver(update).observe(container, { childList: true, subtree: false });
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
container.scrollTop = container.scrollHeight;
|
|
});
|
|
|
|
// When the modal hosting this iframe opens, the container goes from
|
|
// 0 height to its real height. Jump to the latest entry on that edge.
|
|
let wasVisible = container.clientHeight > 0;
|
|
const ro = new ResizeObserver(() => {
|
|
const isVisible = container.clientHeight > 0;
|
|
if (isVisible && !wasVisible) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
wasVisible = isVisible;
|
|
update();
|
|
});
|
|
ro.observe(container);
|
|
}
|