/** * Console SSE — streaming log viewer with SSE, scroll management, * font sizing, resize dragging, and floating UI indicators. * * Replaces the legacy BjornUI.ConsoleSSE IIFE from global.js. * * @module core/console-sse */ import { $, el, toast } from './dom.js'; import { api } from './api.js'; import { t } from './i18n.js'; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const MAX_VISIBLE_LINES = 200; const MAX_RECONNECT = 5; const RECONNECT_DELAY_MS = 2000; const LS_FONT_KEY = 'Console.fontPx'; const LS_DOCK_KEY = 'Console.docked'; const DEFAULT_FONT_PX = 12; const MOBILE_FONT_PX = 11; const MOBILE_BREAKPOINT = 768; /** Map canonical log-level tokens to CSS class names. */ const LEVEL_CLASSES = { DEBUG: 'debug', INFO: 'info', WARNING: 'warning', ERROR: 'error', CRITICAL: 'critical', SUCCESS: 'success', }; /* ------------------------------------------------------------------ */ /* Module state */ /* ------------------------------------------------------------------ */ let evtSource = null; let reconnectCount = 0; let reconnectTimer = null; let isUserScrolling = false; let autoScroll = true; let lineBuffer = []; // lines held while user is scrolled up let isDocked = false; /* Cached DOM refs (populated in init) */ let elConsole = null; let elLogout = null; let elFontInput = null; let elModePill = null; let elModeToggle = null; let elAttackToggle = null; let elDockBtn = null; let elSelIp = null; let elSelPort = null; let elSelAction = null; let elBtnScan = null; let elBtnAttack = null; let elScrollBtn = null; // floating scroll-to-bottom button let elBufferBadge = null; // floating buffer count indicator /* Resize drag state */ let resizeDragging = false; let resizeStartY = 0; let resizeStartH = 0; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** * Deterministic hue from a string (0-359). * @param {string} str * @returns {number} */ function hueFromString(str) { let h = 0; for (let i = 0; i < str.length; i++) { h = (h * 31 + str.charCodeAt(i)) >>> 0; } return h % 360; } /** * Return the default font size based on viewport width. * @returns {number} */ function defaultFontPx() { return window.innerWidth <= MOBILE_BREAKPOINT ? MOBILE_FONT_PX : DEFAULT_FONT_PX; } /** * Update the range input's background gradient so the filled portion * matches the current thumb position. * @param {HTMLInputElement} input */ function paintRangeTrack(input) { if (!input) return; const min = Number(input.min) || 0; const max = Number(input.max) || 100; const val = Number(input.value); const pct = ((val - min) / (max - min)) * 100; input.style.backgroundSize = `${pct}% 100%`; } /* ------------------------------------------------------------------ */ /* Dock / Anchor */ /* ------------------------------------------------------------------ */ function readDockPref() { try { return localStorage.getItem(LS_DOCK_KEY) === '1'; } catch { return false; } } function writeDockPref(on) { try { localStorage.setItem(LS_DOCK_KEY, on ? '1' : '0'); } catch { /* ignore */ } } function syncDockSpace() { if (!elConsole) return; const open = elConsole.classList.contains('open'); const active = !!isDocked && !!open; document.body.classList.toggle('console-docked', active); elConsole.classList.toggle('docked', active); if (elDockBtn) { elDockBtn.classList.toggle('on', !!isDocked); elDockBtn.setAttribute('aria-pressed', String(!!isDocked)); elDockBtn.title = isDocked ? 'Unanchor console' : 'Anchor console'; } const root = document.documentElement; if (!active) { root.style.setProperty('--console-dock-h', '0px'); return; } // Reserve space equal to console height so the app container doesn't sit under it. const h = Math.max(0, Math.round(elConsole.getBoundingClientRect().height)); root.style.setProperty('--console-dock-h', `${h}px`); } function ensureDockButton() { if (!elConsole || elDockBtn) return; const head = elConsole.querySelector('.console-head'); const closeBtn = $('#closeConsole'); if (!head || !closeBtn) return; elDockBtn = el('button', { class: 'btn console-dock-btn', id: 'consoleDock', type: 'button', title: 'Anchor console', 'aria-label': 'Anchor console', 'aria-pressed': 'false', onclick: (e) => { e.preventDefault(); e.stopPropagation(); isDocked = !isDocked; writeDockPref(isDocked); syncDockSpace(); }, }, ['PIN']); head.insertBefore(elDockBtn, closeBtn); } /* ------------------------------------------------------------------ */ /* Log-line processing */ /* ------------------------------------------------------------------ */ /** * Transform a raw log line into an HTML string with highlighted * filenames, log levels, and numbers. * * NOTE: The log content originates from the server's own log stream; * it is NOT user-supplied input, so innerHTML is acceptable here. * * @param {string} line * @returns {string} HTML string */ function processLogLine(line) { // 1. Highlight *.py filenames line = line.replace( /\b([\w\-]+\.py)\b/g, (_match, name) => { const hue = hueFromString(name); return `${name}`; } ); // 2. Highlight canonical log levels const levelPattern = /\b(DEBUG|INFO|WARNING|ERROR|CRITICAL|SUCCESS)\b/g; line = line.replace(levelPattern, (_match, lvl) => { const cls = LEVEL_CLASSES[lvl] || lvl.toLowerCase(); return `${lvl}`; }); // 3. Highlight special-case tokens line = line.replace( /\b(failed)\b/gi, (_m, tok) => `${tok}` ); line = line.replace( /\b(Connected)\b/g, (_m, tok) => `${tok}` ); line = line.replace( /(SSE stream closed)/g, (_m, tok) => `${tok}` ); // 4. Highlight numbers that are NOT inside HTML tags // Strategy: split on HTML tags, process only the text segments. line = line.replace( /(<[^>]*>)|(\b\d+(?:\.\d+)?\b)/g, (match, tag, num) => { if (tag) return tag; // pass tags through return `${num}`; } ); return line; } /* ------------------------------------------------------------------ */ /* Scroll management */ /* ------------------------------------------------------------------ */ function scrollToBottom() { if (!elLogout) return; elLogout.scrollTop = elLogout.scrollHeight; } /** * Determine whether the console body is scrolled to (or near) the bottom. * @returns {boolean} */ function isAtBottom() { if (!elLogout) return true; return elLogout.scrollTop + elLogout.clientHeight >= elLogout.scrollHeight - 8; } /** Flush any buffered lines into the visible log. */ function flushBuffer() { if (!elLogout || lineBuffer.length === 0) return; for (const html of lineBuffer) { appendLogHtml(html, false); } lineBuffer = []; updateBufferBadge(); trimLines(); scrollToBottom(); } /** Trim oldest lines if the visible count exceeds the maximum. */ function trimLines() { if (!elLogout) return; while (elLogout.childElementCount > MAX_VISIBLE_LINES) { elLogout.removeChild(elLogout.firstElementChild); } } /** * Append one processed HTML line into the console body. * @param {string} html * @param {boolean} shouldAutoScroll */ function appendLogHtml(html, shouldAutoScroll = true) { if (!elLogout) return; const div = document.createElement('div'); div.className = 'log-line'; div.innerHTML = html; elLogout.appendChild(div); if (shouldAutoScroll) { trimLines(); if (autoScroll) scrollToBottom(); } } /** Handle scroll events on the console body. */ function onLogScroll() { const atBottom = isAtBottom(); if (!atBottom) { isUserScrolling = true; autoScroll = false; } else { isUserScrolling = false; autoScroll = true; flushBuffer(); } updateFloatingUI(); } /* ------------------------------------------------------------------ */ /* Floating UI (scroll-to-bottom button & buffer badge) */ /* ------------------------------------------------------------------ */ function ensureFloatingUI() { if (elScrollBtn) return; // Scroll-to-bottom button elScrollBtn = el('button', { class: 'console-scroll-btn hidden', title: t('console.scrollToBottom'), onclick: () => forceBottom(), }, ['\u2193']); // Buffer badge elBufferBadge = el('span', { class: 'console-buffer-badge hidden' }, ['0']); if (elConsole) { elConsole.appendChild(elScrollBtn); elConsole.appendChild(elBufferBadge); } } function updateFloatingUI() { if (!elScrollBtn || !elBufferBadge) return; if (!autoScroll && !isAtBottom()) { elScrollBtn.classList.remove('hidden'); } else { elScrollBtn.classList.add('hidden'); } updateBufferBadge(); } function updateBufferBadge() { if (!elBufferBadge) return; if (lineBuffer.length > 0) { elBufferBadge.textContent = String(lineBuffer.length); elBufferBadge.classList.remove('hidden'); } else { elBufferBadge.classList.add('hidden'); } } /* ------------------------------------------------------------------ */ /* SSE connection */ /* ------------------------------------------------------------------ */ function connectSSE() { if (evtSource) return; evtSource = new EventSource('/stream_logs'); evtSource.onmessage = (evt) => { reconnectCount = 0; // healthy connection resets counter const raw = evt.data; if (!raw) return; // Detect Mode Change Logs (Server -> Client Push) // Log format: "... - Operation mode switched to: AI" if (raw.includes('Operation mode switched to:')) { const parts = raw.split('Operation mode switched to:'); if (parts.length > 1) { const newMode = parts[1].trim().split(' ')[0]; // Take first word just in case setModeUI(newMode); } } // --- NEW: AI Dashboard Real-time Events --- if (raw.includes('[AI_EXEC]')) { try { const json = raw.split('[AI_EXEC]')[1].trim(); const data = JSON.parse(json); window.dispatchEvent(new CustomEvent('bjorn:ai_exec', { detail: data })); } catch (e) { console.warn('[ConsoleSSE] Failed to parse AI_EXEC:', e); } } if (raw.includes('[AI_DONE]')) { try { const json = raw.split('[AI_DONE]')[1].trim(); const data = JSON.parse(json); window.dispatchEvent(new CustomEvent('bjorn:ai_done', { detail: data })); } catch (e) { console.warn('[ConsoleSSE] Failed to parse AI_DONE:', e); } } const html = processLogLine(raw); if (isUserScrolling && !autoScroll) { lineBuffer.push(html); updateBufferBadge(); } else { appendLogHtml(html); } }; evtSource.onerror = () => { disconnectSSE(); scheduleReconnect(); }; } function disconnectSSE() { if (evtSource) { evtSource.close(); evtSource = null; } } function scheduleReconnect() { if (reconnectTimer) return; if (reconnectCount >= MAX_RECONNECT) { toast(t('console.maxReconnect'), 4000, 'warning'); return; } reconnectCount++; reconnectTimer = setTimeout(() => { reconnectTimer = null; // Only reconnect if console is still open if (elConsole && elConsole.classList.contains('open')) { connectSSE(); } }, RECONNECT_DELAY_MS); } /* ------------------------------------------------------------------ */ /* Font size */ /* ------------------------------------------------------------------ */ /** * Set the console font size in pixels. Clamped to the range input's * min/max bounds. Persisted to localStorage. * @param {number|string} px */ export function setFont(px) { if (!elConsole || !elFontInput) return; const min = Number(elFontInput.min) || 2; const max = Number(elFontInput.max) || 24; let val = Math.round(Number(px)); if (Number.isNaN(val)) val = defaultFontPx(); val = Math.max(min, Math.min(max, val)); elConsole.style.setProperty('--console-font', `${val}px`); elFontInput.value = val; paintRangeTrack(elFontInput); try { localStorage.setItem(LS_FONT_KEY, String(val)); } catch { /* storage full / blocked */ } } /** Load saved font size or apply sensible default. */ function loadFont() { let saved = null; try { saved = localStorage.getItem(LS_FONT_KEY); } catch { /* blocked */ } const px = saved !== null ? Number(saved) : defaultFontPx(); setFont(px); } /* ------------------------------------------------------------------ */ /* Console resize (drag) */ /* ------------------------------------------------------------------ */ function onResizeStart(e) { e.preventDefault(); resizeDragging = true; resizeStartY = e.type.startsWith('touch') ? e.touches[0].clientY : e.clientY; resizeStartH = elConsole ? elConsole.offsetHeight : 0; document.addEventListener('mousemove', onResizeMove); document.addEventListener('mouseup', onResizeEnd); document.addEventListener('touchmove', onResizeMove, { passive: false }); document.addEventListener('touchend', onResizeEnd); } function onResizeMove(e) { if (!resizeDragging || !elConsole) return; e.preventDefault(); const clientY = e.type.startsWith('touch') ? e.touches[0].clientY : e.clientY; const delta = resizeStartY - clientY; // drag up = larger const newH = Math.max(80, resizeStartH + delta); // floor at 80px elConsole.style.height = `${newH}px`; if (isDocked) syncDockSpace(); } function onResizeEnd() { resizeDragging = false; document.removeEventListener('mousemove', onResizeMove); document.removeEventListener('mouseup', onResizeEnd); document.removeEventListener('touchmove', onResizeMove); document.removeEventListener('touchend', onResizeEnd); if (isDocked) syncDockSpace(); } /* ------------------------------------------------------------------ */ /* Mode / Attack toggles */ /* ------------------------------------------------------------------ */ /** * Set the Mode UI based on the mode string: 'MANUAL', 'AUTO', or 'AI'. * @param {string} mode */ function setModeUI(mode) { if (!elModePill || !elModeToggle) return; // Normalize mode = String(mode || 'AUTO').toUpperCase().trim(); if (mode === 'TRUE') mode = 'MANUAL'; // Legacy fallback if (mode === 'FALSE') mode = 'AUTO'; // Legacy fallback // Default to AUTO if unrecognized if (!['MANUAL', 'AUTO', 'AI'].includes(mode)) { mode = 'AUTO'; } const isManual = mode === 'MANUAL'; const isAi = mode === 'AI'; // Pill classes elModePill.classList.remove('manual', 'auto', 'ai'); if (isManual) { elModePill.classList.add('manual'); } else if (isAi) { elModePill.classList.add('ai'); } else { elModePill.classList.add('auto'); } // Pill Text let pillText = t('console.auto'); if (isManual) pillText = t('console.manual'); if (isAi) pillText = 'AI Mode'; elModePill.innerHTML = ` ${pillText}`; // Toggle Button Text (Show what NEXT click does) // Cycle: MANUAL -> AUTO -> AI -> MANUAL if (isManual) { elModeToggle.textContent = 'Enable Auto'; } else if (isAi) { elModeToggle.textContent = 'Stop (Manual)'; // AI -> Manual is safer "Stop" } else { // Auto elModeToggle.textContent = 'Enable AI'; } elModeToggle.setAttribute('aria-pressed', String(isManual)); showAttackForMode(isManual); } function showAttackForMode(isManual) { const attackBar = $('#attackBar'); if (!elConsole || !attackBar) return; const visible = !!isManual && window.innerWidth > 700; elConsole.classList.toggle('with-attack', visible); attackBar.style.display = visible ? 'flex' : 'none'; if (elAttackToggle) elAttackToggle.setAttribute('aria-expanded', String(visible)); } async function refreshModeFromServer() { try { // Returns "MANUAL", "AUTO", or "AI" string (text/plain) // We must await .text() if the api wrapper returns the fetch response, // but the 'api' helper usually returns parsed JSON or text based on content-type. // Let's assume api.get returns the direct body. // We'll treat it as string and trim it. let mode = await api.get('/check_manual_mode', { timeout: 5000, retries: 0 }); if (typeof mode === 'string') { mode = mode.trim().replace(/^"|"$/g, ''); // Remove quotes if JSON encoded } setModeUI(mode); } catch (e) { // Keep UI as-is } } async function loadManualTargets() { if (!elSelIp || !elSelPort || !elSelAction) return; try { const data = await api.get('/netkb_data_json', { timeout: 10000, retries: 0 }); const ips = Array.isArray(data?.ips) ? data.ips : []; const actions = Array.isArray(data?.actions) ? data.actions : []; const portsByIp = data?.ports && typeof data.ports === 'object' ? data.ports : {}; const currentIp = elSelIp.value; const currentAction = elSelAction.value; elSelIp.innerHTML = ''; if (!ips.length) { const op = document.createElement('option'); op.value = ''; op.textContent = t('console.noTarget'); elSelIp.appendChild(op); } else { for (const ip of ips) { const op = document.createElement('option'); op.value = String(ip); op.textContent = String(ip); elSelIp.appendChild(op); } if (currentIp && ips.includes(currentIp)) elSelIp.value = currentIp; } elSelAction.innerHTML = ''; if (!actions.length) { const op = document.createElement('option'); op.value = ''; op.textContent = t('console.noAction'); elSelAction.appendChild(op); } else { for (const action of actions) { const op = document.createElement('option'); op.value = String(action); op.textContent = String(action); elSelAction.appendChild(op); } if (currentAction && actions.includes(currentAction)) elSelAction.value = currentAction; } updatePortsForSelectedIp(portsByIp); } catch { // Keep existing options if loading fails. } } function updatePortsForSelectedIp(cachedPortsByIp = null) { if (!elSelIp || !elSelPort) return; const render = (ports) => { elSelPort.innerHTML = ''; const list = Array.isArray(ports) ? ports : []; if (!list.length) { const op = document.createElement('option'); op.value = ''; op.textContent = t('console.auto'); elSelPort.appendChild(op); return; } for (const p of list) { const op = document.createElement('option'); op.value = String(p); op.textContent = String(p); elSelPort.appendChild(op); } }; if (cachedPortsByIp && typeof cachedPortsByIp === 'object') { render(cachedPortsByIp[elSelIp.value]); return; } api.get('/netkb_data_json', { timeout: 10000, retries: 0 }) .then((data) => render(data?.ports?.[elSelIp.value])) .catch(() => render([])); } async function runManualScan() { if (!elBtnScan) return; elBtnScan.classList.add('scanning'); try { await api.post('/manual_scan'); toast(t('console.scanStarted'), 1600, 'success'); } catch { toast(t('console.scanFailed'), 2500, 'error'); } finally { setTimeout(() => elBtnScan?.classList.remove('scanning'), 800); } } async function runManualAttack() { if (!elBtnAttack) return; elBtnAttack.classList.add('attacking'); try { await api.post('/manual_attack', { ip: elSelIp?.value || '', port: elSelPort?.value || '', action: elSelAction?.value || '', }); toast(t('console.attackStarted'), 1600, 'success'); } catch { toast(t('console.attackFailed'), 2500, 'error'); } finally { setTimeout(() => elBtnAttack?.classList.remove('attacking'), 900); } } async function toggleMode() { if (!elModePill) return; // Determine current mode from class let current = 'AUTO'; if (elModePill.classList.contains('manual')) current = 'MANUAL'; if (elModePill.classList.contains('ai')) current = 'AI'; // Cycle: MANUAL -> AUTO -> AI -> MANUAL let next = 'AUTO'; if (current === 'MANUAL') next = 'AUTO'; else if (current === 'AUTO') next = 'AI'; else if (current === 'AI') next = 'MANUAL'; try { // Use the new centralized config endpoint const res = await api.post('/api/rl/config', { mode: next }); if (res && res.status === 'ok') { setModeUI(res.mode); toast(`Mode: ${res.mode}`, 2000, 'success'); } else { toast('Failed to change mode', 3000, 'error'); } } catch (e) { console.error(e); toast(t('console.failedToggleMode'), 3000, 'error'); } } async function toggleModeQuick() { if (!elModePill) return; // Quick toggle intended for the pill: // AI <-> AUTO (MANUAL -> AUTO). let current = 'AUTO'; if (elModePill.classList.contains('manual')) current = 'MANUAL'; if (elModePill.classList.contains('ai')) current = 'AI'; let next = 'AUTO'; if (current === 'AI') next = 'AUTO'; else if (current === 'AUTO') next = 'AI'; else if (current === 'MANUAL') next = 'AUTO'; try { const res = await api.post('/api/rl/config', { mode: next }); if (res && res.status === 'ok') { setModeUI(res.mode); toast(`Mode: ${res.mode}`, 2000, 'success'); } else { toast('Failed to change mode', 3000, 'error'); } } catch (e) { console.error(e); toast(t('console.failedToggleMode'), 3000, 'error'); } } function toggleAttackBar() { const attackBar = $('#attackBar'); if (!elConsole || !attackBar) return; const on = !elConsole.classList.contains('with-attack'); elConsole.classList.toggle('with-attack', on); attackBar.style.display = on ? 'flex' : 'none'; if (elAttackToggle) elAttackToggle.setAttribute('aria-expanded', String(on)); } /* ------------------------------------------------------------------ */ /* Console open / close */ /* ------------------------------------------------------------------ */ /** * Open the console panel and start the SSE stream. */ export function openConsole() { if (!elConsole) return; elConsole.classList.add('open'); reconnectCount = 0; start(); syncDockSpace(); } /** * Close the console panel and stop the SSE stream. */ export function closeConsole() { if (!elConsole) return; elConsole.classList.remove('open'); stop(); syncDockSpace(); } /** * Toggle the console between open and closed states. */ export function toggleConsole() { if (!elConsole) return; if (elConsole.classList.contains('open')) { closeConsole(); } else { openConsole(); } } /* ------------------------------------------------------------------ */ /* Public API */ /* ------------------------------------------------------------------ */ /** * Start the SSE log stream (idempotent). */ export function start() { connectSSE(); } /** * Stop the SSE log stream and clear reconnect state. */ export function stop() { disconnectSSE(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectCount = 0; } /** * Toggle the SSE stream on/off. */ export function toggle() { if (evtSource) { stop(); } else { start(); } } /** * Force the console to scroll to the bottom, flushing any buffered * lines and re-enabling auto-scroll. */ export function forceBottom() { autoScroll = true; isUserScrolling = false; flushBuffer(); scrollToBottom(); updateFloatingUI(); } /* ------------------------------------------------------------------ */ /* Initialisation */ /* ------------------------------------------------------------------ */ /** * Initialise the console SSE module. * Wires up all event listeners, loads persisted state, and checks * whether the console should auto-start. */ export function init() { /* Cache DOM references */ elConsole = $('#console'); elLogout = $('#logout'); elFontInput = $('#consoleFont'); elModePill = $('#modePill'); elModeToggle = $('#modeToggle'); elAttackToggle = $('#attackToggle'); elSelIp = $('#selIP'); elSelPort = $('#selPort'); elSelAction = $('#selAction'); elBtnScan = $('#btnScan'); elBtnAttack = $('#btnAttack'); if (!elConsole || !elLogout) { console.warn('[ConsoleSSE] Required DOM elements not found — aborting init.'); return; } /* Floating UI (scroll-to-bottom btn, buffer badge) */ ensureFloatingUI(); isDocked = readDockPref(); ensureDockButton(); syncDockSpace(); /* -- Font size --------------------------------------------------- */ loadFont(); if (elFontInput) { elFontInput.addEventListener('input', () => setFont(elFontInput.value)); } /* -- Close / Clear ----------------------------------------------- */ const btnClose = $('#closeConsole'); if (btnClose) btnClose.addEventListener('click', closeConsole); const btnClear = $('#clearLogs'); if (btnClear) { btnClear.addEventListener('click', () => { if (elLogout) { while (elLogout.firstChild) elLogout.removeChild(elLogout.firstChild); } lineBuffer = []; updateBufferBadge(); }); } /* -- Old behavior: click bottombar to toggle console ------------- */ const bottomBar = $('#bottombar'); if (bottomBar) { bottomBar.addEventListener('click', (e) => { const target = e.target; if (!(target instanceof Element)) return; // Avoid hijacking liveview interactions. if (target.closest('#bjorncharacter') || target.closest('.bjorn-dropdown')) return; toggleConsole(); }); } /* -- Mode toggle ------------------------------------------------- */ if (elModeToggle) { elModeToggle.addEventListener('click', toggleMode); } if (elModePill) { elModePill.addEventListener('click', (e) => { // Prevent bubbling to bottom bar toggle (if nested) e.preventDefault(); e.stopPropagation(); toggleModeQuick(); }); } /* -- Attack bar toggle ------------------------------------------- */ if (elAttackToggle) { elAttackToggle.addEventListener('click', toggleAttackBar); } if (elSelIp) { elSelIp.addEventListener('change', () => updatePortsForSelectedIp()); } if (elBtnScan) { elBtnScan.addEventListener('click', (e) => { e.preventDefault(); runManualScan(); }); } if (elBtnAttack) { elBtnAttack.addEventListener('click', (e) => { e.preventDefault(); runManualAttack(); }); } /* -- Scroll tracking --------------------------------------------- */ elLogout.addEventListener('scroll', onLogScroll); /* -- Console resize ---------------------------------------------- */ const elResize = $('#consoleResize'); if (elResize) { elResize.addEventListener('mousedown', onResizeStart); elResize.addEventListener('touchstart', onResizeStart, { passive: false }); } /* -- Keyboard shortcut: Ctrl + ` to toggle console --------------- */ document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === '`') { e.preventDefault(); toggleConsole(); } }); /* -- Autostart check --------------------------------------------- */ loadManualTargets(); refreshModeFromServer(); window.addEventListener('resize', () => refreshModeFromServer()); // BroadcastChannel for instant Tab-to-Tab sync const bc = new BroadcastChannel('bjorn_mode_sync'); bc.onmessage = (ev) => { if (ev.data && ev.data.mode) { setModeUI(ev.data.mode); } }; checkAutostart(); } /** * Query the server to determine if the console should auto-start. */ async function checkAutostart() { // Keep console closed by default when the web UI loads. // It can still be opened manually by the user. closeConsole(); }