Files
mc-webui/app/static/js/console.js
T
MarekWoandClaude Opus 5 3b34dd9646 feat(i18n): translate the DM panel (stage 6)
Covers app/templates/dm.html and app/static/js/dm.js: the sidebar, the
searchable conversation selector, the composer, message bubbles and their
delivery meta, the Contact Info modal, path management, and the repeater
list/map pickers.

The DM panel's path management and repeater pickers are the code the My
Repeaters panel was adapted from, so its strings reuse the repeaters.*
keys added in stage 3a rather than duplicating them. Shared chat chrome
(filter bar, FAB titles, composer placeholder, status bar) goes to a new
chat.* namespace so index.html and app.js can reuse it in stages 7 and 9,
and console.status.* is promoted to common.connected/disconnected/
connecting now that two status bars want the same three words.

formatRelativeTimeDm() was the last of the four local relative-time
implementations; it is gone, folded into formatTimeAgo() from
datetime-utils.js. The contact-info row uses the long form to match the
contacts page, the map popup the short form to match My Repeaters.

Also fixed along the way, both found by grepping this slice's shared
strings against the already-translated panels:

- repeaters.js reset the map picker label from JS in three places with a
  hardcoded English string, so the header translated on load and flipped
  back to English the moment the picker opened (missed in stage 3a).
- tHtml() on a plural key always renders the "one" form, because only
  tn() passes the count to the resolver. That made the repeater picker's
  shared-prefix tooltip read "3 repeater ma ten prefiks". Fixed in both
  callers and now an error in scripts/i18n_check.py, which had no way to
  catch it.

Left English on purpose: Flood/Direct/FLOOD and the SNR labels (glossary),
and the 'Device path' label written to the DB on import - it is stored
data, so translating it would freeze one language into the row.

Catalog: 471 -> 526 keys, pl at 100%. Verified against the local
container with i18n-diff.js plus a new i18n-stage6-dm.js that drives the
panel through each modal, since the diff tool cannot see hidden ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:38:14 +02:00

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(t('common.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. data.output and data.error are CLI output composed by
// the device/backend — English by design, only the wrapper is translated.
if (data.success) {
addMessage(data.output || t('console.no_output'), 'response');
} else {
addMessage(t('console.error_prefix', { error: data.error }), 'error');
}
scrollToBottom();
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
updateStatus('disconnected');
addMessage(t('console.connect_failed', { error: 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 = t('common.connected');
text.className = 'text-success';
break;
case 'disconnected':
text.textContent = t('common.disconnected');
text.className = 'text-danger';
break;
case 'connecting':
text.textContent = t('common.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">${tHtml('console.history_empty')}</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);
}