Add RLUtils class for managing RL/AI dashboard endpoints

- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
This commit is contained in:
Fabien POLLY
2026-02-18 22:36:10 +01:00
parent b8a13cc698
commit eb20b168a6
684 changed files with 53278 additions and 27977 deletions

437
web/js/core/actions.js Normal file
View File

@@ -0,0 +1,437 @@
/**
* Actions Dropdown — ES module replacement for the monolithic global.js
* actions/dropdown logic. Builds the dropdown menu, wires hover/touch/keyboard
* behaviour, and dispatches action API calls.
*/
import { $, el, toast } from './dom.js';
import { api } from './api.js';
import { t } from './i18n.js';
/* ------------------------------------------------------------------ */
/* Dropdown item definitions */
/* ------------------------------------------------------------------ */
const dropdownItems = [
{ action: 'restart_bjorn_service', textKey: 'actions.menu.restartService', tipKey: 'actions.tip.restartService' },
{ action: 'remove_All_Actions', textKey: 'actions.menu.deleteActionStatus', tipKey: 'actions.tip.deleteActionStatus' },
{ action: 'clear_output_folder', textKey: 'actions.menu.clearOutput', tipKey: 'actions.tip.clearOutput' },
{ action: 'clear_logs', textKey: 'actions.menu.clearLogs', tipKey: 'actions.tip.clearLogs' },
{ action: 'reload_images', textKey: 'actions.menu.reloadImages', tipKey: 'actions.tip.reloadImages' },
{ action: 'reload_fonts', textKey: 'actions.menu.reloadFonts', tipKey: 'actions.tip.reloadFonts' },
{ action: 'reload_generate_actions_json', textKey: 'actions.menu.reloadActionsJson', tipKey: 'actions.tip.reloadActionsJson' },
{ action: 'initialize_csv', textKey: 'actions.menu.initializeCsv', tipKey: 'actions.tip.initializeCsv' },
{ action: 'clear_livestatus', textKey: 'actions.menu.clearLivestatus', tipKey: 'actions.tip.clearLivestatus' },
{ action: 'clear_actions_file', textKey: 'actions.menu.refreshActionsFile', tipKey: 'actions.tip.refreshActionsFile' },
{ action: 'clear_netkb', textKey: 'actions.menu.clearNetkb', tipKey: 'actions.tip.clearNetkb' },
{ action: 'clear_shared_config_json', textKey: 'actions.menu.clearSharedConfig', tipKey: 'actions.tip.clearSharedConfig' },
{ action: 'erase_bjorn_memories', textKey: 'actions.menu.eraseMemories', tipKey: 'actions.tip.eraseMemories' },
{ action: 'reboot_system', textKey: 'actions.menu.reboot', tipKey: 'actions.tip.reboot' },
{ action: 'shutdown_system', textKey: 'actions.menu.shutdown', tipKey: 'actions.tip.shutdown' },
];
/* ------------------------------------------------------------------ */
/* Action handlers — each returns a Promise */
/* ------------------------------------------------------------------ */
/**
* Helper: after a successful action that recommends a service restart,
* prompt the user and fire the restart if they agree.
*/
async function offerRestart() {
if (confirm(t('actions.confirm.restartRecommended'))) {
try {
await api.post('/restart_bjorn_service');
toast(t('actions.msg.restartingService'), 3000, 'success');
} catch (err) {
toast(`${t('actions.msg.restartFailed')}: ${err.message}`, 4000, 'error');
}
}
}
/** Map of action name -> handler function */
const actionHandlers = {
async restart_bjorn_service() {
if (!confirm(t('actions.confirm.restartService'))) return;
await api.post('/restart_bjorn_service');
toast(t('actions.msg.restartingService'), 3000, 'success');
},
async remove_All_Actions() {
if (!confirm(t('actions.confirm.deleteActionStatus'))) return;
await api.post('/delete_all_actions', { ip: '' });
toast(t('actions.msg.actionStatusDeleted'), 3000, 'success');
},
async clear_output_folder() {
if (!confirm(t('actions.confirm.clearOutput'))) return;
await api.post('/clear_output_folder');
toast(t('actions.msg.outputCleared'), 3000, 'success');
},
async clear_logs() {
if (!confirm(t('actions.confirm.clearLogs'))) return;
await api.post('/clear_logs');
toast(t('actions.msg.logsCleared'), 3000, 'success');
},
async clear_netkb() {
if (!confirm(t('actions.confirm.clearNetkb'))) return;
await api.post('/clear_netkb');
toast(t('actions.msg.netkbCleared'), 3000, 'success');
await offerRestart();
},
async clear_livestatus() {
if (!confirm(t('actions.confirm.clearLivestatus'))) return;
await api.post('/clear_livestatus');
toast(t('actions.msg.livestatusDeleted'), 3000, 'success');
await offerRestart();
},
async clear_actions_file() {
if (!confirm(t('actions.confirm.refreshActionsFile'))) return;
await api.post('/clear_actions_file');
toast(t('actions.msg.actionsFileRefreshed'), 3000, 'success');
await offerRestart();
},
async clear_shared_config_json() {
if (!confirm(t('actions.confirm.clearSharedConfig'))) return;
await api.post('/clear_shared_config_json');
toast(t('actions.msg.sharedConfigDeleted'), 3000, 'success');
await offerRestart();
},
async erase_bjorn_memories() {
if (!confirm(t('actions.confirm.eraseMemories'))) return;
await api.post('/erase_bjorn_memories');
toast(t('actions.msg.memoriesErased'), 3000, 'success');
await offerRestart();
},
async reboot_system() {
if (!confirm(t('actions.confirm.reboot'))) return;
await api.post('/reboot_system');
toast(t('actions.msg.rebooting'), 3000, 'success');
},
async shutdown_system() {
if (!confirm(t('actions.confirm.shutdown'))) return;
await api.post('/shutdown_system');
toast(t('actions.msg.shuttingDown'), 3000, 'success');
},
async initialize_csv() {
await api.post('/initialize_csv');
toast(t('actions.msg.csvInitialized'), 3000, 'success');
},
async reload_generate_actions_json() {
await api.post('/reload_generate_actions_json');
toast(t('actions.msg.actionsJsonReloaded'), 3000, 'success');
},
async reload_images() {
await api.post('/reload_images');
toast(t('actions.msg.imagesReloaded'), 3000, 'success');
},
async reload_fonts() {
await api.post('/reload_fonts');
toast(t('actions.msg.fontsReloaded'), 3000, 'success');
},
};
/* ------------------------------------------------------------------ */
/* Dropdown open / close helpers */
/* ------------------------------------------------------------------ */
let actionsBtn = null;
let actionsMenu = null;
let actionsWrap = null;
/** Whether the menu was explicitly toggled open via pointer/keyboard */
let sticky = false;
let hoverTimer = null;
const hoverMQ = window.matchMedia('(hover: hover) and (pointer: fine)');
function openMenu() {
if (!actionsMenu || !actionsBtn) return;
actionsMenu.style.display = 'block';
actionsMenu.hidden = false;
actionsMenu.classList.add('open');
actionsMenu.setAttribute('aria-hidden', 'false');
actionsBtn.setAttribute('aria-expanded', 'true');
placeActionsMenu();
}
function closeMenu() {
if (!actionsMenu || !actionsBtn) return;
actionsMenu.classList.remove('open');
actionsMenu.setAttribute('aria-hidden', 'true');
actionsBtn.setAttribute('aria-expanded', 'false');
actionsMenu.hidden = true;
actionsMenu.style.display = '';
sticky = false;
}
function isOpen() {
return actionsMenu && actionsMenu.classList.contains('open');
}
/**
* Position the dropdown menu beneath the topbar, horizontally centered.
*/
function placeActionsMenu() {
if (!actionsMenu || !actionsBtn) return;
const btnRect = actionsBtn.getBoundingClientRect();
const top = Math.round(btnRect.bottom + 6);
const margin = 8;
actionsMenu.style.position = 'fixed';
actionsMenu.style.top = `${top}px`;
actionsMenu.style.left = '0px';
actionsMenu.style.transform = 'none';
const menuWidth = actionsMenu.offsetWidth || 320;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 1024;
const maxLeft = Math.max(margin, viewportWidth - menuWidth - margin);
let left = Math.round(btnRect.left + (btnRect.width - menuWidth) / 2);
left = Math.max(margin, Math.min(maxLeft, left));
actionsMenu.style.left = `${left}px`;
}
/* ------------------------------------------------------------------ */
/* Build the menu items into the DOM */
/* ------------------------------------------------------------------ */
function buildMenu() {
if (!actionsMenu) return;
// Clear any existing children (idempotent rebuild)
while (actionsMenu.firstChild) actionsMenu.removeChild(actionsMenu.firstChild);
for (const item of dropdownItems) {
const btn = el('button', {
class: 'dropdown-item',
role: 'menuitem',
tabindex: '-1',
title: t(item.tipKey),
'data-action': item.action,
}, [t(item.textKey)]);
actionsMenu.appendChild(btn);
}
}
/* ------------------------------------------------------------------ */
/* Execute an action by name */
/* ------------------------------------------------------------------ */
async function executeAction(actionName) {
const handler = actionHandlers[actionName];
if (!handler) {
toast(`${t('actions.msg.unknownAction')}: ${actionName}`, 3000, 'error');
return;
}
try {
await handler();
} catch (err) {
toast(`${t('actions.msg.actionFailed')}: ${err.message}`, 4000, 'error');
}
}
/* ------------------------------------------------------------------ */
/* Keyboard navigation helpers */
/* ------------------------------------------------------------------ */
function getMenuItems() {
if (!actionsMenu) return [];
return Array.from(actionsMenu.querySelectorAll('[role="menuitem"]'));
}
function focusItem(items, index) {
if (index < 0 || index >= items.length) return;
items[index].focus();
}
/* ------------------------------------------------------------------ */
/* Event wiring */
/* ------------------------------------------------------------------ */
function wireEvents() {
if (!actionsBtn || !actionsMenu || !actionsWrap) return;
/* -- Hover behavior (desktop only) -- */
actionsWrap.addEventListener('mouseenter', () => {
if (!hoverMQ.matches) return;
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
if (!sticky) openMenu();
});
actionsWrap.addEventListener('mouseleave', () => {
if (!hoverMQ.matches) return;
if (sticky) return;
hoverTimer = setTimeout(() => {
hoverTimer = null;
if (!sticky) closeMenu();
}, 150);
});
/* -- Button toggle (desktop + mobile) -- */
let lastToggleTime = 0;
function toggleFromButton(e) {
e.preventDefault();
e.stopPropagation();
// Guard against double-firing (pointerup + click both fire on mobile tap)
const now = Date.now();
if (now - lastToggleTime < 300) return;
lastToggleTime = now;
if (isOpen()) {
closeMenu();
} else {
sticky = true;
openMenu();
}
}
actionsBtn.addEventListener('click', toggleFromButton);
actionsBtn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') toggleFromButton(e);
});
/* -- Close on pointerdown outside -- */
document.addEventListener('pointerdown', (e) => {
if (!isOpen()) return;
if (!actionsWrap.contains(e.target)) {
closeMenu();
}
});
/* -- Menu item clicks -- */
actionsMenu.addEventListener('click', (e) => {
const item = e.target.closest('[data-action]');
if (!item) return;
const actionName = item.getAttribute('data-action');
closeMenu();
executeAction(actionName);
});
/* -- Keyboard navigation -- */
actionsWrap.addEventListener('keydown', (e) => {
const items = getMenuItems();
if (!items.length) return;
const currentIndex = items.indexOf(document.activeElement);
switch (e.key) {
case 'Escape':
e.preventDefault();
closeMenu();
actionsBtn.focus();
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen()) {
openMenu();
focusItem(items, 0);
} else {
focusItem(items, currentIndex < items.length - 1 ? currentIndex + 1 : 0);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen()) {
openMenu();
focusItem(items, items.length - 1);
} else {
focusItem(items, currentIndex > 0 ? currentIndex - 1 : items.length - 1);
}
break;
case 'Home':
if (isOpen()) {
e.preventDefault();
focusItem(items, 0);
}
break;
case 'End':
if (isOpen()) {
e.preventDefault();
focusItem(items, items.length - 1);
}
break;
case 'Enter':
case ' ':
if (document.activeElement && document.activeElement.hasAttribute('data-action')) {
e.preventDefault();
const actionName = document.activeElement.getAttribute('data-action');
closeMenu();
executeAction(actionName);
}
break;
default:
break;
}
});
/* -- Reposition on resize / scroll -- */
window.addEventListener('resize', () => {
if (isOpen()) placeActionsMenu();
});
window.addEventListener('scroll', () => {
if (isOpen()) placeActionsMenu();
}, { passive: true });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) closeMenu();
});
window.addEventListener('hashchange', closeMenu);
}
function onLanguageChanged() {
buildMenu();
if (isOpen()) placeActionsMenu();
}
/* ------------------------------------------------------------------ */
/* Public init — idempotent */
/* ------------------------------------------------------------------ */
let _initialised = false;
/**
* Initialise the Actions dropdown.
* Safe to call once; subsequent calls are no-ops.
*/
export function init() {
if (_initialised) return;
actionsBtn = $('#actionsBtn');
actionsMenu = $('#actionsMenu');
actionsWrap = $('#actionsWrap');
if (!actionsBtn || !actionsMenu || !actionsWrap) {
console.warn('[actions] Required DOM elements not found; skipping init.');
return;
}
buildMenu();
wireEvents();
window.addEventListener('i18n:changed', onLanguageChanged);
_initialised = true;
console.debug('[actions] initialised');
}

178
web/js/core/api.js Normal file
View File

@@ -0,0 +1,178 @@
/**
* API client wrapper — fetch with timeout, abort, retry, backoff.
* Provides Poller utility with adaptive intervals and visibility awareness.
*/
import { t } from './i18n.js';
const DEFAULT_TIMEOUT = 10000; // 10s
const MAX_RETRIES = 2;
const BACKOFF = [200, 800]; // ms per retry
/** Consistent error shape */
class ApiError extends Error {
constructor(message, status = 0, data = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
/**
* Core fetch wrapper with timeout + abort + retry.
* @param {string} url
* @param {object} opts - fetch options + {timeout, retries, signal}
* @returns {Promise<any>}
*/
async function request(url, opts = {}) {
const {
timeout = DEFAULT_TIMEOUT,
retries = MAX_RETRIES,
signal: externalSignal,
...fetchOpts
} = opts;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeout);
// Link external signal if provided
if (externalSignal) {
if (externalSignal.aborted) { clearTimeout(timer); throw new ApiError('Aborted', 0); }
externalSignal.addEventListener('abort', () => ac.abort(), { once: true });
}
try {
const res = await fetch(url, { ...fetchOpts, signal: ac.signal });
clearTimeout(timer);
if (!res.ok) {
let body = null;
try { body = await res.json(); } catch { /* not JSON */ }
throw new ApiError(body?.message || res.statusText, res.status, body);
}
// Parse response
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return await res.json();
if (ct.includes('text/')) return await res.text();
return res;
} catch (err) {
clearTimeout(timer);
lastError = err;
// Don't retry on abort or client errors (4xx)
if (err.name === 'AbortError' || err.name === 'ApiError') {
if (err.name === 'AbortError') throw new ApiError(t('api.timeout'), 0);
if (err.status >= 400 && err.status < 500) throw err;
}
// Retry with backoff for transient errors
if (attempt < retries) {
const delay = BACKOFF[attempt] || BACKOFF[BACKOFF.length - 1];
await new Promise(r => setTimeout(r, delay));
continue;
}
}
}
throw lastError || new ApiError(t('api.failed'));
}
/* -- Convenience methods -- */
export const api = {
get(url, opts = {}) {
return request(url, { method: 'GET', ...opts });
},
post(url, data, opts = {}) {
const isFormData = data instanceof FormData;
return request(url, {
method: 'POST',
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
body: isFormData ? data : JSON.stringify(data),
...opts
});
},
del(url, opts = {}) {
return request(url, { method: 'DELETE', ...opts });
},
ApiError
};
/**
* Poller — adaptive polling with visibility awareness.
* Slows down when document is hidden, stops on unmount.
*
* Usage:
* const p = new Poller(() => fetch('/status'), 5000);
* p.start(); // begins polling
* p.stop(); // stops (call in unmount)
*/
export class Poller {
/**
* @param {Function} fn - async function to call each tick
* @param {number} interval - base interval in ms
* @param {object} opts - { hiddenMultiplier, maxInterval, immediate }
*/
constructor(fn, interval, opts = {}) {
this._fn = fn;
this._baseInterval = interval;
this._hiddenMultiplier = opts.hiddenMultiplier || 4;
this._maxInterval = opts.maxInterval || 120000; // 2min cap
this._immediate = opts.immediate !== false;
this._timer = null;
this._running = false;
this._onVisibility = this._handleVisibility.bind(this);
}
start() {
if (this._running) return;
this._running = true;
document.addEventListener('visibilitychange', this._onVisibility);
if (this._immediate) this._tick();
else this._schedule();
console.debug(`[Poller] started (${this._baseInterval}ms)`);
}
stop() {
this._running = false;
clearTimeout(this._timer);
this._timer = null;
document.removeEventListener('visibilitychange', this._onVisibility);
console.debug('[Poller] stopped');
}
_currentInterval() {
if (document.hidden) {
return Math.min(this._baseInterval * this._hiddenMultiplier, this._maxInterval);
}
return this._baseInterval;
}
async _tick() {
if (!this._running) return;
try {
await this._fn();
} catch (err) {
console.warn('[Poller] tick error:', err.message);
}
this._schedule();
}
_schedule() {
if (!this._running) return;
clearTimeout(this._timer);
this._timer = setTimeout(() => this._tick(), this._currentInterval());
}
_handleVisibility() {
// Reschedule with adjusted interval when visibility changes
if (this._running) this._schedule();
}
}

998
web/js/core/console-sse.js Normal file
View File

@@ -0,0 +1,998 @@
/**
* 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 `<span class="logfile" style="--h:${hue}">${name}</span>`;
}
);
// 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 `<span class="loglvl ${cls}">${lvl}</span>`;
});
// 3. Highlight special-case tokens
line = line.replace(
/\b(failed)\b/gi,
(_m, tok) => `<span class="loglvl failed">${tok}</span>`
);
line = line.replace(
/\b(Connected)\b/g,
(_m, tok) => `<span class="loglvl connected">${tok}</span>`
);
line = line.replace(
/(SSE stream closed)/g,
(_m, tok) => `<span class="loglvl sseclosed">${tok}</span>`
);
// 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 `<span class="number">${num}</span>`;
}
);
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 = `<span class="dot"></span> ${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();
}

97
web/js/core/dom.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* Safe DOM utilities — avoids innerHTML with untrusted content.
*/
import { trLoose } from './i18n.js';
/**
* Create an element with attributes and children (safe, no innerHTML).
* @param {string} tag
* @param {object} attrs - className, style, data-*, event handlers (onclick, etc.)
* @param {Array} children - strings or HTMLElements
* @returns {HTMLElement}
*/
export function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class' || k === 'className') node.className = v;
else if (k === 'style' && typeof v === 'string') node.style.cssText = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') {
node.addEventListener(k.slice(2).toLowerCase(), v);
}
else node.setAttribute(k, String(v));
}
for (const child of (Array.isArray(children) ? children : [children])) {
if (child == null || child === false) continue;
if (typeof child === 'string' || typeof child === 'number') {
node.appendChild(document.createTextNode(String(child)));
} else if (child instanceof Node) {
node.appendChild(child);
}
}
return node;
}
/**
* Shorthand selectors.
*/
export const $ = (s, root = document) => root.querySelector(s);
export const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
/**
* Escape HTML entities to prevent XSS when rendering untrusted text.
* @param {string} str
* @returns {string}
*/
export function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
/**
* Set text content safely (never innerHTML with untrusted data).
* @param {HTMLElement} el
* @param {string} text
*/
export function setText(el, text) {
if (el) el.textContent = text;
}
/**
* Show a toast notification.
* @param {string} message - plain text (safe)
* @param {number} duration - ms
* @param {string} type - 'info' | 'success' | 'error' | 'warning'
*/
export function toast(message, duration = 2600, type = 'info') {
const container = document.getElementById('toasts');
if (!container) return;
const t = el('div', { class: `toast toast-${type}` }, [trLoose(String(message))]);
container.appendChild(t);
setTimeout(() => {
t.style.transition = 'transform .2s ease, opacity .2s';
t.style.transform = 'translateY(10px)';
t.style.opacity = '0';
setTimeout(() => t.remove(), 220);
}, duration);
}
/**
* Empty a container safely.
* @param {HTMLElement} container
*/
export function empty(container) {
while (container.firstChild) container.removeChild(container.firstChild);
}
export function confirmT(message) {
return window.confirm(trLoose(String(message)));
}
export function promptT(message, defaultValue = '') {
return window.prompt(trLoose(String(message)), defaultValue);
}

270
web/js/core/i18n.js Normal file
View File

@@ -0,0 +1,270 @@
/**
* i18n module — loads JSON translation files, provides t() helper,
* supports dynamic re-render via data-i18n attributes.
*
* Key convention: page.section.element
* e.g. "nav.dashboard", "console.title", "settings.theme.colorPrimary"
*
* Fallback: missing key in current lang -> EN -> dev warning.
*/
const SUPPORTED = ['en', 'fr', 'es', 'de', 'it', 'ru', 'zh'];
const STORAGE_KEY = 'bjorn_lang';
const CACHE = {}; // { lang: { key: string } }
let _currentLang = 'en';
let _fallback = {}; // EN always loaded as fallback
let _reverseFallback = null; // { "English text": "some.key" }
/** Load a language JSON file */
async function loadLang(lang) {
if (CACHE[lang]) return CACHE[lang];
try {
const res = await fetch(`/web/i18n/${lang}.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
CACHE[lang] = await res.json();
return CACHE[lang];
} catch (err) {
console.warn(`[i18n] Failed to load ${lang}:`, err.message);
return {};
}
}
/**
* Resolve a dotted key from a flat or nested object.
* Supports flat keys ("nav.dashboard") and nested ({ nav: { dashboard: "..." } }).
*/
function resolve(dict, key) {
// Try flat key first
if (key in dict) return dict[key];
// Try nested
const parts = key.split('.');
let node = dict;
for (const p of parts) {
if (node == null || typeof node !== 'object') return undefined;
node = node[p];
}
return typeof node === 'string' ? node : undefined;
}
function flattenStrings(dict, out = {}, prefix = '') {
if (!dict || typeof dict !== 'object') return out;
for (const [k, v] of Object.entries(dict)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === 'string') out[key] = v;
else if (v && typeof v === 'object') flattenStrings(v, out, key);
}
return out;
}
function buildReverseFallback() {
const flat = flattenStrings(_fallback);
const rev = {};
for (const [k, v] of Object.entries(flat)) {
if (!v || typeof v !== 'string') continue;
if (!(v in rev)) rev[v] = k;
}
_reverseFallback = rev;
}
function translateLooseText(value) {
const text = String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return text;
if (!_reverseFallback) buildReverseFallback();
const key = _reverseFallback?.[trimmed];
if (!key) return text;
const translated = t(key);
if (!translated || translated === key) return text;
const start = text.indexOf(trimmed);
if (start < 0) return translated;
return text.slice(0, start) + translated + text.slice(start + trimmed.length);
}
export function trLoose(value) {
return translateLooseText(value);
}
/**
* Translate a key with optional variable interpolation.
* Variables use {{name}} syntax: t('greeting', { name: 'Bjorn' })
* @param {string} key
* @param {object} vars
* @returns {string}
*/
export function t(key, vars = {}) {
const dict = CACHE[_currentLang] || {};
let str = resolve(dict, key);
// Fallback to EN
if (str === undefined) {
str = resolve(_fallback, key);
if (str === undefined) {
console.warn(`[i18n] Missing key: "${key}" (lang=${_currentLang})`);
return key; // Return key itself as last resort
}
}
// Interpolate {{var}}
if (vars && typeof str === 'string') {
str = str.replace(/\{\{(\w+)\}\}/g, (_, name) => {
return vars[name] !== undefined ? String(vars[name]) : `{{${name}}}`;
});
}
return str;
}
/**
* Get current language code.
*/
export function currentLang() {
return _currentLang;
}
/**
* Get list of supported languages.
*/
export function supportedLangs() {
return [...SUPPORTED];
}
/**
* Initialize i18n: load saved language or detect from browser.
*/
export async function init() {
// Load EN fallback first
_fallback = await loadLang('en');
CACHE['en'] = _fallback;
buildReverseFallback();
// Detect preferred language
const saved = localStorage.getItem(STORAGE_KEY);
const browser = (navigator.language || '').slice(0, 2).toLowerCase();
const lang = saved || (SUPPORTED.includes(browser) ? browser : 'en');
await setLang(lang);
}
/**
* Switch language, reload translations, update DOM.
* @param {string} lang
*/
export async function setLang(lang) {
if (!SUPPORTED.includes(lang)) {
console.warn(`[i18n] Unsupported language: ${lang}, falling back to en`);
lang = 'en';
}
_currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
if (!CACHE[lang]) {
await loadLang(lang);
}
// Update all [data-i18n] elements in the DOM
updateDOM();
window.dispatchEvent(new CustomEvent('i18n:changed', { detail: { lang } }));
}
/**
* Update all DOM elements with data-i18n attribute.
* Minimal re-render: only touches elements that need text updates.
*/
export function updateDOM(root = document) {
const els = root.querySelectorAll('[data-i18n]');
for (const el of els) {
const key = el.getAttribute('data-i18n');
const translated = t(key);
if (el.textContent !== translated) {
el.textContent = translated;
}
}
// Also handle [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]
for (const attr of ['placeholder', 'title', 'aria-label']) {
const dataAttr = `data-i18n-${attr}`;
const els2 = root.querySelectorAll(`[${dataAttr}]`);
for (const el of els2) {
const key = el.getAttribute(dataAttr);
const translated = t(key);
if (el.getAttribute(attr) !== translated) {
el.setAttribute(attr, translated);
}
}
}
// Fallback auto-translation for still-hardcoded EN labels.
const skipSel = [
'[data-no-i18n]',
'script',
'style',
'pre',
'code',
'textarea',
'input',
'select',
'option',
'#logout',
'.console-body',
'.attacks-log',
'.paneLog',
'.console-output',
'.editor-textarea',
].join(',');
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node?.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (parent.closest(skipSel)) return NodeFilter.FILTER_REJECT;
if (parent.hasAttribute('data-i18n')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const next = translateLooseText(node.nodeValue);
if (next !== node.nodeValue) node.nodeValue = next;
}
for (const attr of ['placeholder', 'title', 'aria-label']) {
const els3 = root.querySelectorAll(`[${attr}]`);
for (const el of els3) {
if (el.hasAttribute(`data-i18n-${attr}`)) continue;
const current = el.getAttribute(attr);
const next = translateLooseText(current);
if (next !== current) el.setAttribute(attr, next);
}
}
}
/**
* Build a language selector UI and mount it into a container.
* @param {HTMLElement} container
*/
export function mountLangSelector(container) {
const LANG_LABELS = {
en: 'EN', fr: 'FR', es: 'ES', de: 'DE', it: 'IT', ru: 'RU', zh: 'ZH'
};
const select = document.createElement('select');
select.className = 'lang-selector';
select.setAttribute('aria-label', t('settings.language'));
for (const code of SUPPORTED) {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = LANG_LABELS[code] || code.toUpperCase();
if (code === _currentLang) opt.selected = true;
select.appendChild(opt);
}
select.addEventListener('change', () => setLang(select.value));
container.innerHTML = '';
container.appendChild(select);
}

686
web/js/core/quickpanel.js Normal file
View File

@@ -0,0 +1,686 @@
/**
* QuickPanel — WiFi & Bluetooth management panel.
*
* Replicates the monolithic global.js QuickPanel as a standalone ES module.
* Slide-down panel with two tabs (WiFi / Bluetooth), scan controls,
* auto-scan toggles, known-network management, and Bluetooth pairing.
*/
import { $, $$, el, toast, empty } from './dom.js';
import { api } from './api.js';
import { t } from './i18n.js';
/* ---------- API endpoints ---------- */
const API = {
scanWifi: '/scan_wifi',
getKnownWifi: '/get_known_wifi',
connectKnown: '/connect_known_wifi',
connectWifi: '/connect_wifi',
updatePriority: '/update_wifi_priority',
deleteKnown: '/delete_known_wifi',
importPotfiles: '/import_potfiles',
scanBluetooth: '/scan_bluetooth',
pairBluetooth: '/pair_bluetooth',
trustBluetooth: '/trust_bluetooth',
connectBluetooth: '/connect_bluetooth',
disconnectBluetooth: '/disconnect_bluetooth',
forgetBluetooth: '/forget_bluetooth',
};
/* ---------- Constants ---------- */
const AUTOSCAN_INTERVAL = 15_000; // 15 s
const LS_WIFI_AUTO = 'qp_wifi_auto';
const LS_BT_AUTO = 'qp_bt_auto';
/* ---------- Module state ---------- */
let panel; // #quickpanel element
let wifiList; // container for wifi scan results
let knownList; // container for known networks
let btList; // container for bluetooth results
let wifiTab; // wifi tab content wrapper
let btTab; // bluetooth tab content wrapper
let tabBtns; // [wifiTabBtn, btTabBtn]
let wifiAutoTimer = null;
let btAutoTimer = null;
let activeTab = 'wifi';
let scanning = { wifi: false, bt: false };
/* =================================================================
Helpers
================================================================= */
/** Persist and read auto-scan preference. */
function getAutoScan(key) {
try { return localStorage.getItem(key) === '1'; } catch { return false; }
}
function setAutoScan(key, on) {
try { localStorage.setItem(key, on ? '1' : '0'); } catch { /* storage full */ }
}
/** Signal strength to bar count (1-4). */
function signalBars(dbm) {
if (dbm > -50) return 4;
if (dbm > -65) return 3;
if (dbm > -75) return 2;
return 1;
}
/** Build a `<span class="sig">` with four bar elements. */
function sigEl(dbm) {
const count = signalBars(dbm);
const bars = [];
for (let i = 1; i <= 4; i++) {
const bar = el('i');
bar.style.height = `${4 + i * 3}px`;
if (i <= count) bar.className = 'on';
bars.push(bar);
}
return el('span', { class: 'sig' }, bars);
}
/** Security type to badge class suffix. */
function secClass(sec) {
if (!sec) return 'sec-open';
const s = sec.toUpperCase();
if (s.includes('WPA')) return 'sec-wpa';
if (s.includes('WEP')) return 'sec-wep';
if (s === 'OPEN' || s === '' || s === 'NONE') return 'sec-open';
return 'sec-wpa'; // default to wpa for unknown secured types
}
/** Security badge element. */
function secBadge(sec) {
const label = sec || 'Open';
return el('span', { class: `badge ${secClass(sec)}` }, [label]);
}
/** State dot element (paired / connected indicator). */
function stateDot(on) {
return el('span', { class: `state-dot ${on ? 'state-on' : 'state-off'}` });
}
/** Create a small auto-scan toggle with a switch. */
function autoScanToggle(key, onChange) {
const isOn = getAutoScan(key);
const sw = el('span', { class: `switch${isOn ? ' on' : ''}`, role: 'switch', 'aria-checked': String(isOn), tabindex: '0' });
const label = el('span', { style: 'font-size:12px;color:var(--muted);user-select:none' }, [t('quick.autoScan')]);
const wrap = el('label', { style: 'display:inline-flex;align-items:center;gap:8px;cursor:pointer' }, [label, sw]);
function toggle() {
const next = !sw.classList.contains('on');
sw.classList.toggle('on', next);
sw.setAttribute('aria-checked', String(next));
setAutoScan(key, next);
onChange(next);
}
sw.addEventListener('click', toggle);
sw.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
return { wrap, isOn };
}
/* =================================================================
System Dialog (WiFi password prompt)
================================================================= */
function openSysDialog(title, fields, onSubmit) {
const backdrop = $('#sysDialogBackdrop');
if (!backdrop) return;
empty(backdrop);
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', style: 'padding:20px;max-width:400px;width:90vw;border-radius:16px;background:var(--grad-quickpanel,#0a1116);border:1px solid var(--c-border-strong)' });
const heading = el('h3', { style: 'margin:0 0 16px;color:var(--ink)' }, [title]);
modal.appendChild(heading);
const form = el('form', { style: 'display:flex;flex-direction:column;gap:12px' });
const inputs = {};
for (const f of fields) {
const input = el('input', {
class: 'input',
type: f.type || 'text',
placeholder: f.placeholder || '',
autocomplete: f.autocomplete || 'off',
style: 'width:100%;padding:10px 12px;border-radius:8px;border:1px solid var(--c-border-strong);background:var(--c-panel);color:var(--ink);font-size:14px',
});
if (f.value) input.value = f.value;
if (f.readonly) input.readOnly = true;
inputs[f.name] = input;
const label = el('label', { style: 'display:flex;flex-direction:column;gap:4px' }, [
el('span', { style: 'font-size:12px;color:var(--muted)' }, [f.label]),
input,
]);
form.appendChild(label);
}
const btnRow = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px' });
const cancelBtn = el('button', { class: 'btn', type: 'button' }, [t('common.cancel')]);
const submitBtn = el('button', { class: 'btn', type: 'submit', style: 'background:var(--acid);color:var(--ink-invert,#001014)' }, [t('common.connect')]);
btnRow.appendChild(cancelBtn);
btnRow.appendChild(submitBtn);
form.appendChild(btnRow);
modal.appendChild(form);
backdrop.appendChild(modal);
backdrop.style.display = 'flex';
backdrop.classList.add('show');
function closeDlg() {
backdrop.style.display = 'none';
backdrop.classList.remove('show');
empty(backdrop);
}
cancelBtn.addEventListener('click', closeDlg);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) closeDlg();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const values = {};
for (const [name, inp] of Object.entries(inputs)) values[name] = inp.value;
closeDlg();
onSubmit(values);
});
// Focus first editable input
const firstInput = Object.values(inputs).find(i => !i.readOnly);
if (firstInput) requestAnimationFrame(() => firstInput.focus());
}
function closeSysDialog() {
const backdrop = $('#sysDialogBackdrop');
if (!backdrop) return;
backdrop.style.display = 'none';
backdrop.classList.remove('show');
empty(backdrop);
}
/* =================================================================
WiFi — scan, connect, known networks
================================================================= */
async function scanWifi() {
if (scanning.wifi) return;
scanning.wifi = true;
try {
const data = await api.get(API.scanWifi);
renderWifiResults(data);
} catch (err) {
toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
} finally {
scanning.wifi = false;
}
}
function renderWifiResults(data) {
if (!wifiList) return;
empty(wifiList);
const networks = Array.isArray(data) ? data : (data?.networks || data?.results || []);
if (!networks.length) {
wifiList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
// Sort by signal descending
networks.sort((a, b) => (b.signal ?? -100) - (a.signal ?? -100));
for (const net of networks) {
const ssid = net.ssid || net.SSID || '(Hidden)';
const signal = net.signal ?? net.level ?? -80;
const sec = net.security || net.encryption || '';
const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto auto auto;align-items:center' }, [
el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]),
sigEl(signal),
secBadge(sec),
el('button', { class: 'btn', onclick: () => promptWifiConnect(ssid, sec), style: 'font-size:12px;padding:4px 10px' }, [t('common.connect')]),
]);
wifiList.appendChild(row);
}
}
function promptWifiConnect(ssid, sec) {
const isOpen = !sec || sec.toUpperCase() === 'OPEN' || sec.toUpperCase() === 'NONE' || sec === '';
if (isOpen) {
connectWifi(ssid, '');
return;
}
openSysDialog(t('quick.connectWifi'), [
{ name: 'ssid', label: t('network.title'), value: ssid, readonly: true },
{ name: 'password', label: t('creds.password'), type: 'password', placeholder: t('creds.password'), autocomplete: 'current-password' },
], (vals) => {
connectWifi(vals.ssid, vals.password);
});
}
async function connectWifi(ssid, password) {
try {
toast(t('quick.connectingTo', { ssid }), 2000, 'info');
await api.post(API.connectWifi, { ssid, password });
toast(t('quick.connectedTo', { ssid }), 3000, 'success');
} catch (err) {
toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
/* ---------- Known networks ---------- */
async function loadKnownWifi() {
if (!knownList) return;
empty(knownList);
knownList.appendChild(el('div', { style: 'padding:8px;color:var(--muted);text-align:center' }, [t('common.loading')]));
try {
const data = await api.get(API.getKnownWifi);
renderKnownNetworks(data);
} catch (err) {
empty(knownList);
toast(t('quick.loadKnownFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
function renderKnownNetworks(data) {
if (!knownList) return;
empty(knownList);
let networks = [];
if (Array.isArray(data)) {
networks = data;
} else if (data && typeof data === 'object') {
networks = data.networks || data.known || data.data || data.results || [];
// If data is a single-key object wrapping an array, unwrap it
if (!networks.length) {
const keys = Object.keys(data);
if (keys.length === 1 && Array.isArray(data[keys[0]])) {
networks = data[keys[0]];
}
}
}
console.debug('[QuickPanel] Known networks data:', data, '-> parsed:', networks.length, 'items');
if (!networks.length) {
knownList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
for (let i = 0; i < networks.length; i++) {
const net = networks[i];
const ssid = net.ssid || net.SSID || '(Unknown)';
const priority = net.priority ?? i;
const moveUpBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, priority + 1), title: t('common.ascending') }, ['\u2191']);
const moveDownBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, Math.max(0, priority - 1)), title: t('common.descending') }, ['\u2193']);
const connectBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => connectKnownWifi(ssid) }, [t('common.connect')]);
const deleteBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => deleteKnown(ssid) }, [t('common.delete')]);
const actions = el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [moveUpBtn, moveDownBtn, connectBtn, deleteBtn]);
const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto;align-items:center' }, [
el('div', { style: 'display:flex;flex-direction:column;gap:2px;overflow:hidden' }, [
el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, ['Priority: ' + priority]),
]),
actions,
]);
knownList.appendChild(row);
}
}
async function connectKnownWifi(ssid) {
try {
toast(t('quick.connectingTo', { ssid }), 2000, 'info');
await api.post(API.connectKnown, { ssid });
toast(t('quick.connectedTo', { ssid }), 3000, 'success');
} catch (err) {
toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
async function updatePriority(ssid, priority) {
try {
await api.post(API.updatePriority, { ssid, priority });
toast(t('quick.priorityUpdated'), 2000, 'success');
loadKnownWifi(); // refresh list
} catch (err) {
toast(t('quick.priorityUpdateFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
async function deleteKnown(ssid) {
openSysDialog(t('common.delete'), [
{ name: 'ssid', label: t('quick.forgetNetworkPrompt'), value: ssid, readonly: true },
], async (vals) => {
try {
await api.post(API.deleteKnown, { ssid: vals.ssid });
toast('Network removed', 2000, 'success');
loadKnownWifi();
} catch (err) {
toast('Delete failed: ' + (err.message || 'Unknown error'), 3000, 'error');
}
});
}
async function importPotfiles() {
try {
toast(t('quick.importingPotfiles'), 2000, 'info');
const res = await api.post(API.importPotfiles);
const count = res?.imported ?? res?.count ?? '?';
toast(t('quick.importedCount', { count }), 3000, 'success');
} catch (err) {
toast(t('studio.importFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
/* =================================================================
Bluetooth — scan, pair, trust, connect, disconnect, forget
================================================================= */
async function scanBluetooth() {
if (scanning.bt) return;
scanning.bt = true;
try {
const data = await api.get(API.scanBluetooth);
renderBtResults(data);
} catch (err) {
toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
} finally {
scanning.bt = false;
}
}
function renderBtResults(data) {
if (!btList) return;
empty(btList);
const devices = Array.isArray(data) ? data : (data?.devices || data?.results || []);
if (!devices.length) {
btList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
for (const dev of devices) {
const name = dev.name || dev.Name || '(Unknown)';
const mac = dev.mac || dev.address || dev.MAC || '';
const type = dev.type || dev.Type || '';
const paired = !!(dev.paired || dev.Paired);
const connected = !!(dev.connected || dev.Connected);
// Action buttons vary by device state
const actions = [];
if (!paired) {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('pair', mac, name) }, [t('quick.pair')]));
} else {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('trust', mac, name) }, [t('quick.trust')]));
if (connected) {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('disconnect', mac, name) }, [t('common.disconnect')]));
} else {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('connect', mac, name) }, [t('common.connect')]));
}
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => btForget(mac, name) }, [t('common.remove')]));
}
const row = el('div', { class: 'qprow btlist' }, [
el('div', { class: 'bt-device' }, [
stateDot(connected),
el('span', { style: 'font-weight:600' }, [name]),
el('span', { class: 'bt-type' }, [type]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, [mac]),
]),
el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, actions),
]);
btList.appendChild(row);
}
}
async function btAction(action, mac, name) {
const endpoints = {
pair: API.pairBluetooth,
trust: API.trustBluetooth,
connect: API.connectBluetooth,
disconnect: API.disconnectBluetooth,
};
const url = endpoints[action];
if (!url) return;
const label = action.charAt(0).toUpperCase() + action.slice(1);
try {
toast(t('quick.btActioning', { action, name }), 2000, 'info');
await api.post(url, { address: mac, mac });
toast(t('quick.btActionDone', { action, name }), 3000, 'success');
// Refresh after state change
scanBluetooth();
} catch (err) {
toast(t('quick.btActionFailed', { action }) + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
function btForget(mac, name) {
openSysDialog(t('quick.forgetDevice'), [
{ name: 'mac', label: t('quick.forgetDevicePrompt', { name }), value: mac, readonly: true },
], async (vals) => {
try {
await api.post(API.forgetBluetooth, { address: vals.mac, mac: vals.mac });
toast(t('quick.btForgotten', { name }), 2000, 'success');
scanBluetooth();
} catch (err) {
toast(t('common.deleteFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
});
}
/* =================================================================
Auto-scan timers
================================================================= */
function startWifiAutoScan() {
stopWifiAutoScan();
wifiAutoTimer = setInterval(() => {
if (panel && panel.classList.contains('open') && activeTab === 'wifi') scanWifi();
}, AUTOSCAN_INTERVAL);
// Immediate first scan
scanWifi();
}
function stopWifiAutoScan() {
if (wifiAutoTimer) { clearInterval(wifiAutoTimer); wifiAutoTimer = null; }
}
function startBtAutoScan() {
stopBtAutoScan();
btAutoTimer = setInterval(() => {
if (panel && panel.classList.contains('open') && activeTab === 'bt') scanBluetooth();
}, AUTOSCAN_INTERVAL);
scanBluetooth();
}
function stopBtAutoScan() {
if (btAutoTimer) { clearInterval(btAutoTimer); btAutoTimer = null; }
}
/* =================================================================
Tab switching
================================================================= */
function switchTab(tab) {
activeTab = tab;
if (tabBtns) {
tabBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
}
if (wifiTab) wifiTab.style.display = (tab === 'wifi') ? '' : 'none';
if (btTab) btTab.style.display = (tab === 'bt') ? '' : 'none';
}
/* =================================================================
Panel open / close / toggle
================================================================= */
export function open() {
if (!panel) return;
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
// Load known networks on open (always useful to have them)
loadKnownWifi();
// Start auto-scans if enabled
if (getAutoScan(LS_WIFI_AUTO)) startWifiAutoScan();
if (getAutoScan(LS_BT_AUTO)) startBtAutoScan();
}
export function close() {
if (!panel) return;
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
// Stop auto-scans while closed to save resources
stopWifiAutoScan();
stopBtAutoScan();
// Close any open system dialog
closeSysDialog();
}
export function toggle() {
if (!panel) return;
if (panel.classList.contains('open')) close();
else open();
}
/* =================================================================
Build panel content (init)
================================================================= */
export function init() {
panel = $('#quickpanel');
if (!panel) {
console.warn('[QuickPanel] #quickpanel not found in DOM');
return;
}
/* ---- Header ---- */
const closeBtn = el('button', { class: 'qp-close', 'aria-label': t('quick.close'), onclick: close }, ['\u2715']);
const header = el('div', { class: 'qp-header', style: 'padding:20px 16px 8px' }, [
el('div', { class: 'qp-head-left' }, [
el('strong', { style: 'font-size:16px' }, [t('nav.shortcuts')]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, [t('quick.subtitle')]),
]),
closeBtn,
]);
/* ---- Tab bar ---- */
const wifiTabBtn = el('div', { class: 'tab active', 'data-tab': 'wifi', onclick: () => switchTab('wifi') }, [t('dash.wifi')]);
const btTabBtn = el('div', { class: 'tab', 'data-tab': 'bt', onclick: () => switchTab('bt') }, [t('dash.bluetooth')]);
tabBtns = [wifiTabBtn, btTabBtn];
const tabBar = el('div', { class: 'tabs-container', style: 'margin:0 16px 12px' }, [wifiTabBtn, btTabBtn]);
/* ---- WiFi tab content ---- */
wifiList = el('div', { class: 'wifilist', style: 'max-height:40vh;overflow-y:auto;padding:0 16px' });
knownList = el('div', { class: 'knownlist', style: 'max-height:30vh;overflow-y:auto;padding:0 16px' });
const wifiScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanWifi }, [t('common.refresh')]);
const knownBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: loadKnownWifi }, [t('quick.knownNetworks')]);
const potfileBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: importPotfiles }, [t('quick.importPotfiles')]);
const wifiAutoCtrl = autoScanToggle(LS_WIFI_AUTO, (on) => {
if (on && panel.classList.contains('open')) startWifiAutoScan();
else stopWifiAutoScan();
});
const wifiToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [
wifiScanBtn, knownBtn, potfileBtn,
el('span', { style: 'flex:1' }),
wifiAutoCtrl.wrap,
]);
const knownHeader = el('div', { style: 'padding:8px 16px 4px;font-weight:700;font-size:13px;color:var(--muted)' }, [t('quick.knownNetworks')]);
wifiTab = el('div', { 'data-panel': 'wifi' }, [wifiToolbar, wifiList, knownHeader, knownList]);
/* ---- Bluetooth tab content ---- */
btList = el('div', { class: 'btlist', style: 'max-height:50vh;overflow-y:auto;padding:0 16px' });
const btScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanBluetooth }, [t('common.refresh')]);
const btAutoCtrl = autoScanToggle(LS_BT_AUTO, (on) => {
if (on && panel.classList.contains('open')) startBtAutoScan();
else stopBtAutoScan();
});
const btToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [
btScanBtn,
el('span', { style: 'flex:1' }),
btAutoCtrl.wrap,
]);
btTab = el('div', { 'data-panel': 'bt', style: 'display:none' }, [btToolbar, btList]);
/* ---- Assemble into panel (after the grip) ---- */
panel.appendChild(header);
panel.appendChild(tabBar);
panel.appendChild(wifiTab);
panel.appendChild(btTab);
/* ---- Global keyboard shortcuts ---- */
document.addEventListener('keydown', onKeyDown);
/* ---- Click outside to close ---- */
document.addEventListener('pointerdown', onOutsideClick);
/* ---- Wire topbar trigger button ---- */
const openBtn = $('#openQuick');
if (openBtn) openBtn.addEventListener('click', toggle);
}
/* =================================================================
Event handlers
================================================================= */
function onKeyDown(e) {
// Ctrl+\ to toggle
if (e.ctrlKey && e.key === '\\') {
e.preventDefault();
toggle();
return;
}
// Escape to close
if (e.key === 'Escape' && panel && panel.classList.contains('open')) {
// If a system dialog is open, close that first
const dlg = $('#sysDialogBackdrop');
if (dlg && (dlg.style.display === 'flex' || dlg.classList.contains('show'))) {
closeSysDialog();
return;
}
close();
}
}
function onOutsideClick(e) {
if (!panel || !panel.classList.contains('open')) return;
// Ignore clicks inside the panel itself
if (panel.contains(e.target)) return;
// Ignore clicks on the trigger button
const openBtn = $('#openQuick');
if (openBtn && openBtn.contains(e.target)) return;
// Ignore clicks on the system dialog backdrop
const dlg = $('#sysDialogBackdrop');
if (dlg && dlg.contains(e.target)) return;
close();
}

View File

@@ -0,0 +1,103 @@
/**
* ResourceTracker — tracks intervals, timeouts, listeners, AbortControllers.
* Each page module gets one tracker; calling cleanupAll() on unmount
* guarantees zero leaked resources.
*/
export class ResourceTracker {
constructor(label = 'anon') {
this._label = label;
this._intervals = new Set();
this._timeouts = new Set();
this._listeners = []; // {target, event, handler, options}
this._abortControllers = new Set();
}
/* -- Intervals -- */
trackInterval(fn, ms) {
const id = setInterval(fn, ms);
this._intervals.add(id);
return id;
}
clearTrackedInterval(id) {
clearInterval(id);
this._intervals.delete(id);
}
/* -- Timeouts -- */
trackTimeout(fn, ms) {
const id = setTimeout(() => {
this._timeouts.delete(id);
fn();
}, ms);
this._timeouts.add(id);
return id;
}
clearTrackedTimeout(id) {
clearTimeout(id);
this._timeouts.delete(id);
}
/* -- Event listeners -- */
trackEventListener(target, event, handler, options) {
target.addEventListener(event, handler, options);
this._listeners.push({ target, event, handler, options });
}
/* -- AbortControllers (for fetch) -- */
trackAbortController() {
const ac = new AbortController();
this._abortControllers.add(ac);
return ac;
}
removeAbortController(ac) {
this._abortControllers.delete(ac);
}
/* -- Cleanup everything -- */
cleanupAll() {
// Intervals
for (const id of this._intervals) clearInterval(id);
this._intervals.clear();
// Timeouts
for (const id of this._timeouts) clearTimeout(id);
this._timeouts.clear();
// Listeners & Generic cleanups
for (const item of this._listeners) {
if (item.cleanup) {
try { item.cleanup(); } catch (err) { console.warn(`[ResourceTracker:${this._label}] cleanup error`, err); }
} else if (item.target) {
item.target.removeEventListener(item.event, item.handler, item.options);
}
}
this._listeners.length = 0;
// Abort controllers
for (const ac of this._abortControllers) {
try { ac.abort(); } catch { /* already aborted */ }
}
this._abortControllers.clear();
}
/* -- Generic resources -- */
trackResource(fn) {
if (typeof fn === 'function') {
this._listeners.push({ cleanup: fn });
}
}
/* -- Diagnostics -- */
stats() {
return {
label: this._label,
intervals: this._intervals.size,
timeouts: this._timeouts.size,
listeners: this._listeners.length,
abortControllers: this._abortControllers.size
};
}
}

134
web/js/core/router.js Normal file
View File

@@ -0,0 +1,134 @@
/**
* Hash-based SPA router.
* Routes map to lazy-loaded page modules (ES modules with mount/unmount).
*
* Each page module must export:
* mount(container, ctx): void | Promise<void>
* unmount(): void
* onRouteParams?(params): void [optional]
*
* The router guarantees unmount() is called before switching pages.
*/
import { updateDOM as updateI18n, t } from './i18n.js';
/** @type {Map<string, () => Promise<{mount, unmount, onRouteParams?}>>} */
const _routes = new Map();
let _currentModule = null;
let _currentRoute = null;
let _container = null;
let _notFoundHandler = null;
/**
* Register a route.
* @param {string} path - hash path without '#', e.g. '/dashboard'
* @param {Function} loader - async function returning the module, e.g. () => import('../pages/dashboard.js')
*/
export function route(path, loader) {
_routes.set(path, loader);
}
/**
* Set a fallback handler for unknown routes.
* @param {Function} handler - (container, hash) => void
*/
export function setNotFound(handler) {
_notFoundHandler = handler;
}
/**
* Initialize the router.
* @param {HTMLElement} container - the element to mount pages into (e.g. #app)
*/
export function init(container) {
_container = container;
window.addEventListener('hashchange', () => _resolve());
// Initial route
_resolve();
}
/**
* Force remount of the current route (used for i18n/theme refresh).
*/
export function reloadCurrent() {
_resolve(true);
}
/**
* Programmatic navigation.
* @param {string} path - e.g. '/dashboard'
*/
export function navigate(path) {
window.location.hash = '#' + path;
}
/**
* Get current route path.
*/
export function currentRoute() {
return _currentRoute;
}
/* -- Internal -- */
async function _resolve(force = false) {
const hash = window.location.hash.slice(1) || '/dashboard'; // default
const [path, queryStr] = hash.split('?');
const params = Object.fromEntries(new URLSearchParams(queryStr || ''));
// If same route, just update params
if (!force && path === _currentRoute && _currentModule?.onRouteParams) {
_currentModule.onRouteParams(params);
return;
}
// Unmount previous
if (_currentModule) {
try {
_currentModule.unmount();
} catch (err) {
console.error(`[Router] Error unmounting ${_currentRoute}:`, err);
}
_currentModule = null;
}
// Clear container
_container.innerHTML = '';
_currentRoute = path;
// Find matching route
const loader = _routes.get(path);
if (!loader) {
if (_notFoundHandler) {
_notFoundHandler(_container, path);
} else {
_container.textContent = t('router.notFound', { path });
}
return;
}
// Loading indicator
_container.setAttribute('aria-busy', 'true');
try {
const mod = await loader();
_currentModule = mod;
// Mount the page
await mod.mount(_container, { params, navigate });
// Update i18n labels in the newly mounted content
updateI18n(_container);
// Pass route params if handler exists
if (mod.onRouteParams) {
mod.onRouteParams(params);
}
} catch (err) {
console.error(`[Router] Error loading ${path}:`, err);
_container.textContent = t('router.errorLoading', { message: err.message });
} finally {
_container.removeAttribute('aria-busy');
}
}

View File

@@ -0,0 +1,376 @@
import { $, el, toast, empty } from './dom.js';
import { api } from './api.js';
import { t } from './i18n.js';
const API = {
load: '/load_config',
save: '/save_config',
restore: '/restore_default_config',
};
const DEFAULT_RANGE = { min: 0, max: 100, step: 1 };
const RANGES = {
web_delay: { min: 0, max: 10000, step: 1 },
screen_delay: { min: 0, max: 10, step: 0.1 },
startup_delay: { min: 0, max: 600, step: 0.1 },
startup_splash_duration: { min: 0, max: 60, step: 0.1 },
fullrefresh_delay: { min: 0, max: 3600, step: 1 },
image_display_delaymin: { min: 0, max: 600, step: 0.1 },
image_display_delaymax: { min: 0, max: 600, step: 0.1 },
comment_delaymin: { min: 0, max: 600, step: 0.1 },
comment_delaymax: { min: 0, max: 600, step: 0.1 },
shared_update_interval: { min: 1, max: 86400, step: 1 },
livestatus_delay: { min: 0, max: 600, step: 0.1 },
ref_width: { min: 32, max: 1024, step: 1 },
ref_height: { min: 32, max: 1024, step: 1 },
vuln_max_ports: { min: 1, max: 65535, step: 1 },
portstart: { min: 0, max: 65535, step: 1 },
portend: { min: 0, max: 65535, step: 1 },
frise_default_x: { min: 0, max: 2000, step: 1 },
frise_default_y: { min: 0, max: 2000, step: 1 },
frise_epd2in7_x: { min: 0, max: 2000, step: 1 },
frise_epd2in7_y: { min: 0, max: 2000, step: 1 },
semaphore_slots: { min: 1, max: 128, step: 1 },
line_spacing: { min: 0, max: 10, step: 0.1 },
vuln_update_interval: { min: 1, max: 86400, step: 1 },
};
let _host = null;
let _lastConfig = null;
function resolveTooltips(config) {
const tips = config?.__tooltips_i18n__;
if (!tips || typeof tips !== 'object' || Array.isArray(tips)) return {};
return tips;
}
function createFieldLabel(key, forId = null, tooltipI18nKey = '') {
const attrs = {};
if (forId) attrs.for = forId;
if (tooltipI18nKey) {
attrs['data-i18n-title'] = tooltipI18nKey;
attrs.title = t(tooltipI18nKey);
}
return el('label', attrs, [key]);
}
function getRangeForKey(key, value) {
if (RANGES[key]) return RANGES[key];
const n = Number(value);
if (Number.isFinite(n)) {
if (n <= 10) return { min: 0, max: 10, step: 1 };
if (n <= 100) return { min: 0, max: 100, step: 1 };
if (n <= 1000) return { min: 0, max: 1000, step: 1 };
return { min: 0, max: Math.ceil(n * 2), step: Math.max(1, Math.round(n / 100)) };
}
return DEFAULT_RANGE;
}
function normalizeNumber(raw) {
const s = String(raw ?? '').trim().replace(',', '.');
if (!s || s === '-' || s === '.' || s === '-.') return NaN;
const n = parseFloat(s);
return Number.isFinite(n) ? n : NaN;
}
function ensureChipHelpers() {
if (window.Chips) return;
const makeChip = (text) => {
const chip = el('div', { class: 'cfg-chip' }, [
el('span', {}, [text]),
el('button', { class: 'cfg-chip-close', type: 'button', 'aria-label': 'Remove' }, ['x']),
]);
return chip;
};
document.addEventListener('click', (e) => {
const close = e.target.closest('.cfg-chip-close');
if (close) close.closest('.cfg-chip')?.remove();
});
document.addEventListener('keydown', async (e) => {
if (!e.target || !(e.target instanceof HTMLInputElement)) return;
const input = e.target;
const wrap = input.closest('.cfg-chip-input');
if (!wrap) return;
if (e.key !== 'Enter' && e.key !== ',') return;
e.preventDefault();
const list = wrap.parentElement.querySelector('.cfg-chip-list');
if (!list) return;
const values = input.value
.split(',')
.map(v => v.trim())
.filter(Boolean);
if (!values.length) return;
const existing = new Set(Array.from(list.querySelectorAll('.cfg-chip span')).map(s => s.textContent));
values.forEach(v => {
if (existing.has(v)) return;
list.appendChild(makeChip(v));
});
input.value = '';
});
document.addEventListener('click', async (e) => {
const chip = e.target.closest('.cfg-chip');
if (!chip || e.target.closest('.cfg-chip-close')) return;
if (!window.ChipsEditor) return;
const span = chip.querySelector('span');
const cur = span?.textContent || '';
const next = await window.ChipsEditor.open({
value: cur,
title: t('settings.editValue'),
label: t('common.value'),
multiline: false,
});
if (next === null) return;
const val = String(next).trim();
if (!val) {
chip.remove();
return;
}
const list = chip.parentElement;
const exists = Array.from(list.querySelectorAll('.cfg-chip span')).some(s => s !== span && s.textContent === val);
if (exists) return;
if (span) span.textContent = val;
});
window.Chips = {
values(root) {
return Array.from(root.querySelectorAll('.cfg-chip span')).map(s => s.textContent);
},
setValues(root, values = []) {
empty(root);
values.forEach(v => root.appendChild(makeChip(String(v))));
},
};
}
function createBooleanField(key, value, tooltipI18nKey = '') {
return el('div', { class: 'cfg-field cfg-toggle-row', 'data-key': key, 'data-type': 'boolean' }, [
createFieldLabel(key, `cfg_${key}`, tooltipI18nKey),
el('label', { class: 'switch' }, [
el('input', { id: `cfg_${key}`, type: 'checkbox', ...(value ? { checked: '' } : {}) }),
el('span', { class: 'slider' }),
]),
]);
}
function createNumberField(key, value, tooltipI18nKey = '') {
const range = getRangeForKey(key, value);
const n = Number.isFinite(Number(value)) ? Number(value) : range.min;
const row = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'number' }, [
createFieldLabel(key, `cfg_${key}`, tooltipI18nKey),
el('div', { class: 'cfg-number' }, [
el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'dec' }, ['-']),
el('input', {
id: `cfg_${key}`,
class: 'input cfg-number-input',
type: 'text',
inputmode: 'decimal',
value: String(n).replace('.', ','),
}),
el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'inc' }, ['+']),
]),
el('input', {
class: 'cfg-range',
type: 'range',
min: String(range.min),
max: String(range.max),
step: String(range.step),
value: String(Math.min(range.max, Math.max(range.min, n))),
}),
]);
const textInput = row.querySelector('.cfg-number-input');
const slider = row.querySelector('.cfg-range');
const decBtn = row.querySelector('[data-act="dec"]');
const incBtn = row.querySelector('[data-act="inc"]');
const clamp = (v) => Math.max(range.min, Math.min(range.max, v));
const paint = () => {
const cur = Number(slider.value);
const pct = ((cur - range.min) * 100) / (range.max - range.min || 1);
slider.style.backgroundSize = `${pct}% 100%`;
};
const syncFromText = () => {
const parsed = normalizeNumber(textInput.value);
if (Number.isFinite(parsed)) {
slider.value = String(clamp(parsed));
paint();
}
};
const syncFromRange = () => {
textInput.value = String(slider.value).replace('.', ',');
paint();
};
const nudge = (dir) => {
const parsed = normalizeNumber(textInput.value);
const base = Number.isFinite(parsed) ? parsed : Number(slider.value);
const next = +(base + dir * range.step).toFixed(10);
textInput.value = String(next).replace('.', ',');
slider.value = String(clamp(next));
paint();
};
textInput.addEventListener('input', syncFromText);
textInput.addEventListener('change', syncFromText);
slider.addEventListener('input', syncFromRange);
decBtn.addEventListener('click', () => nudge(-1));
incBtn.addEventListener('click', () => nudge(1));
paint();
return row;
}
function createListField(key, value, tooltipI18nKey = '') {
const list = Array.isArray(value) ? value : [];
const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'list' }, [
createFieldLabel(key, null, tooltipI18nKey),
el('div', { class: 'cfg-chip-list' }),
el('div', { class: 'cfg-chip-input' }, [
el('input', { class: 'input', type: 'text', placeholder: t('settings.addValues') }),
]),
]);
const chipList = node.querySelector('.cfg-chip-list');
window.Chips.setValues(chipList, list);
return node;
}
function createStringField(key, value, tooltipI18nKey = '') {
const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'string' }, [
createFieldLabel(key, null, tooltipI18nKey),
el('div', { class: 'cfg-chip-list' }),
el('div', { class: 'cfg-chip-input' }, [
el('input', { class: 'input', type: 'text', placeholder: t('settings.setValue') }),
]),
]);
const chipList = node.querySelector('.cfg-chip-list');
if (value !== undefined && value !== null && String(value) !== '') {
window.Chips.setValues(chipList, [String(value)]);
}
return node;
}
function createSectionCard(title) {
return el('div', { class: 'card cfg-card' }, [
el('div', { class: 'head' }, [el('h3', { class: 'title' }, [title])]),
el('div', { class: 'cfg-card-body' }),
]);
}
function render(config) {
if (!_host) return;
empty(_host);
ensureChipHelpers();
const tooltips = resolveTooltips(config);
const togglesCard = createSectionCard(t('settings.toggles'));
const togglesBody = togglesCard.querySelector('.cfg-card-body');
const cardsGrid = el('div', { class: 'cfg-cards-grid' });
let currentCard = null;
for (const [key, value] of Object.entries(config || {})) {
if (key.startsWith('__')) {
if (key.startsWith('__title_')) {
if (currentCard) cardsGrid.appendChild(currentCard);
currentCard = createSectionCard(String(value).replace('__title_', '').replace(/__/g, ''));
}
continue;
}
const tooltipI18nKey = String(tooltips[key] || '');
if (typeof value === 'boolean') {
togglesBody.appendChild(createBooleanField(key, value, tooltipI18nKey));
continue;
}
if (!currentCard) currentCard = createSectionCard(t('settings.general'));
const body = currentCard.querySelector('.cfg-card-body');
if (Array.isArray(value)) body.appendChild(createListField(key, value, tooltipI18nKey));
else if (typeof value === 'number') body.appendChild(createNumberField(key, value, tooltipI18nKey));
else body.appendChild(createStringField(key, value, tooltipI18nKey));
}
if (currentCard) cardsGrid.appendChild(currentCard);
_host.appendChild(togglesCard);
_host.appendChild(cardsGrid);
}
function collect() {
const payload = {};
if (!_host) return payload;
_host.querySelectorAll('.cfg-field[data-key]').forEach(field => {
const key = field.getAttribute('data-key');
const type = field.getAttribute('data-type');
if (!key || !type) return;
if (type === 'boolean') {
payload[key] = !!field.querySelector('input[type="checkbox"]')?.checked;
return;
}
if (type === 'number') {
const n = normalizeNumber(field.querySelector('.cfg-number-input')?.value);
payload[key] = Number.isFinite(n) ? n : 0;
return;
}
if (type === 'list') {
payload[key] = window.Chips.values(field.querySelector('.cfg-chip-list'));
return;
}
if (type === 'string') {
const values = window.Chips.values(field.querySelector('.cfg-chip-list'));
payload[key] = values[0] ?? '';
}
});
return payload;
}
export async function loadConfig(host = _host) {
if (host) _host = host;
if (!_host) return;
try {
const config = await api.get(API.load, { timeout: 15000, retries: 0 });
_lastConfig = config;
render(config);
} catch (err) {
toast(`${t('settings.errorLoading')}: ${err.message}`, 3200, 'error');
}
}
export async function saveConfig() {
if (!_host) return;
try {
const payload = collect();
await api.post(API.save, payload, { timeout: 20000, retries: 0 });
toast(t('settings.configSaved'), 2200, 'success');
} catch (err) {
toast(`${t('settings.errorSaving')}: ${err.message}`, 3200, 'error');
}
}
export async function restoreDefaults(host = _host) {
if (host) _host = host;
if (!_host) return;
try {
const config = await api.get(API.restore, { timeout: 20000, retries: 0 });
_lastConfig = config;
render(config);
toast(t('settings.defaultsRestored'), 2200, 'success');
} catch (err) {
toast(`${t('settings.errorRestoring')}: ${err.message}`, 3200, 'error');
}
}
export function mountConfig(host) {
_host = host || _host;
}
export function hasLoadedConfig() {
return !!_lastConfig;
}

View File

@@ -0,0 +1,131 @@
import { t } from './i18n.js';
/**
* Shared page sidebar layout controller.
* Provides one common desktop/mobile behavior for pages with left sidebars.
*/
export function initSharedSidebarLayout(root, opts = {}) {
if (!root) return () => { };
const sidebarSelector = opts.sidebarSelector || '.page-sidebar';
const mainSelector = opts.mainSelector || '.page-main';
const storageKey = opts.storageKey || '';
const mobileBreakpoint = Number(opts.mobileBreakpoint || 900);
const toggleLabel = String(opts.toggleLabel || t('sidebar.close'));
const mobileDefaultOpen = !!opts.mobileDefaultOpen;
const sidebar = root.querySelector(sidebarSelector);
const main = root.querySelector(mainSelector);
if (!sidebar || !main) return () => { };
root.classList.add('page-with-sidebar');
sidebar.classList.add('page-sidebar');
main.classList.add('page-main');
const media = window.matchMedia(`(max-width: ${mobileBreakpoint}px)`);
let desktopHidden = false;
let mobileOpen = false;
if (storageKey) {
try {
desktopHidden = localStorage.getItem(storageKey) === '1';
} catch {
desktopHidden = false;
}
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn sidebar-toggle-btn sidebar-fab sidebar-fab-unified';
btn.innerHTML = '<span aria-hidden="true">☰</span>';
btn.title = toggleLabel;
btn.setAttribute('aria-label', toggleLabel);
const backdrop = document.createElement('button');
backdrop.type = 'button';
backdrop.className = 'page-sidebar-backdrop';
backdrop.setAttribute('aria-label', 'Close sidebar');
if (!root.querySelector(':scope > .sidebar-fab')) {
root.appendChild(btn);
}
if (!root.querySelector(':scope > .page-sidebar-backdrop')) {
root.appendChild(backdrop);
}
function setDesktopHidden(next) {
desktopHidden = !!next;
root.classList.toggle('sidebar-collapsed', desktopHidden);
if (storageKey) {
try { localStorage.setItem(storageKey, desktopHidden ? '1' : '0'); } catch { }
}
refreshFabVisibility();
}
function setMobileOpen(next) {
mobileOpen = !!next;
root.classList.toggle('sidebar-open', mobileOpen);
refreshFabVisibility();
}
function syncMode() {
if (media.matches) {
root.classList.add('sidebar-mobile');
root.classList.remove('sidebar-collapsed');
setMobileOpen(mobileDefaultOpen);
} else {
root.classList.remove('sidebar-mobile');
setMobileOpen(false);
setDesktopHidden(desktopHidden);
}
refreshFabVisibility();
}
function refreshFabVisibility() {
if (media.matches) {
btn.style.display = mobileOpen ? 'none' : '';
return;
}
btn.style.display = desktopHidden ? '' : 'none';
}
function onToggle() {
if (media.matches) {
setMobileOpen(!mobileOpen);
} else {
setDesktopHidden(!desktopHidden);
}
}
function onHideBtn() {
if (media.matches) setMobileOpen(false);
else setDesktopHidden(true);
}
function onBackdrop() {
if (media.matches) setMobileOpen(false);
}
btn.addEventListener('click', onToggle);
backdrop.addEventListener('click', onBackdrop);
media.addEventListener('change', syncMode);
const hideBtn = sidebar.querySelector('#hideSidebar, [data-hide-sidebar="1"]');
if (hideBtn) hideBtn.addEventListener('click', onHideBtn);
syncMode();
refreshFabVisibility();
return () => {
btn.removeEventListener('click', onToggle);
backdrop.removeEventListener('click', onBackdrop);
media.removeEventListener('change', syncMode);
if (hideBtn) hideBtn.removeEventListener('click', onHideBtn);
if (btn.parentNode) btn.parentNode.removeChild(btn);
if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
root.classList.remove('sidebar-open', 'sidebar-collapsed', 'sidebar-mobile', 'page-with-sidebar');
sidebar.classList.remove('page-sidebar');
main.classList.remove('page-main');
};
}

278
web/js/core/theme.js Normal file
View File

@@ -0,0 +1,278 @@
/**
* Theme module — CSS variable management, persistence, theme editor UI.
* Single source of truth: all colors come from :root CSS variables.
*
* Supports:
* - Preset themes (default Nordic Acid, light, etc.)
* - User custom overrides persisted to localStorage
* - Theme editor with color pickers + raw CSS textarea
* - Icon pack switching via icon registry
*/
import { t } from './i18n.js';
const STORAGE_KEY = 'bjorn_theme';
const ICON_PACK_KEY = 'bjorn_icon_pack';
/* Default theme tokens — matches global.css :root */
const DEFAULT_THEME = {
'--bg': '#050709',
'--bg-2': '#0b0f14',
'--ink': '#e6fff7',
'--muted': '#8affc1cc',
'--acid': '#00ff9a',
'--acid-2': '#18f0ff',
'--danger': '#ff3b3b',
'--warning': '#ffd166',
'--ok': '#2cff7e',
'--accent': '#22f0b4',
'--accent-2': '#18d6ff',
'--c-border': '#00ffff22',
'--c-border-strong': '#00ffff33',
'--panel': '#0e1717',
'--panel-2': '#101c1c',
'--c-panel': '#0b1218',
'--radius': '14px'
};
/* Editable token groups for the theme editor */
const TOKEN_GROUPS = [
{
label: 'theme.group.colors',
tokens: [
{ key: '--bg', label: 'theme.token.bg', type: 'color' },
{ key: '--ink', label: 'theme.token.ink', type: 'color' },
{ key: '--acid', label: 'theme.token.accent1', type: 'color' },
{ key: '--acid-2', label: 'theme.token.accent2', type: 'color' },
{ key: '--danger', label: 'theme.token.danger', type: 'color' },
{ key: '--warning', label: 'theme.token.warning', type: 'color' },
{ key: '--ok', label: 'theme.token.ok', type: 'color' },
]
},
{
label: 'theme.group.surfaces',
tokens: [
{ key: '--panel', label: 'theme.token.panel', type: 'color' },
{ key: '--panel-2', label: 'theme.token.panel2', type: 'color' },
{ key: '--c-panel', label: 'theme.token.ctrlPanel', type: 'color' },
{ key: '--c-border', label: 'theme.token.border', type: 'color' },
]
},
{
label: 'theme.group.layout',
tokens: [
{ key: '--radius', label: 'theme.token.radius', type: 'text' },
]
}
];
let _userOverrides = {};
/* -- Icon registry -- */
const _iconPacks = {
default: {} // populated from /web/images/*.png
};
let _currentPack = 'default';
/* Load user theme from localStorage */
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) _userOverrides = JSON.parse(raw);
} catch { _userOverrides = {}; }
try {
_currentPack = localStorage.getItem(ICON_PACK_KEY) || 'default';
} catch { _currentPack = 'default'; }
}
/* Apply overrides to :root */
function applyToDOM() {
const root = document.documentElement;
// Reset to defaults first
for (const [k, v] of Object.entries(DEFAULT_THEME)) {
root.style.setProperty(k, v);
}
// Apply user overrides on top
for (const [k, v] of Object.entries(_userOverrides)) {
if (v) root.style.setProperty(k, v);
}
}
/* Save overrides to localStorage */
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_userOverrides));
} catch { /* storage full or blocked */ }
}
/* -- Public API -- */
export function init() {
loadSaved();
applyToDOM();
}
/** Get current value for a token */
export function getToken(key) {
return _userOverrides[key] || DEFAULT_THEME[key] || '';
}
/** Set a single token override */
export function setToken(key, value) {
_userOverrides[key] = value;
document.documentElement.style.setProperty(key, value);
persist();
}
/** Reset all overrides to default */
export function resetToDefault() {
_userOverrides = {};
persist();
applyToDOM();
}
/** Apply a full theme preset */
export function applyPreset(preset) {
_userOverrides = { ...preset };
persist();
applyToDOM();
}
/** Get current overrides (for display in editor) */
export function getCurrentOverrides() {
return { ...DEFAULT_THEME, ..._userOverrides };
}
/* -- Icon registry -- */
/**
* Register an icon pack.
* @param {string} name
* @param {object} icons - { logicalName: svgString | url }
*/
export function registerIconPack(name, icons) {
_iconPacks[name] = icons;
}
/** Get an icon by logical name from current pack */
export function icon(name) {
const pack = _iconPacks[_currentPack] || _iconPacks.default;
return pack[name] || _iconPacks.default[name] || '';
}
/** Switch icon pack */
export function setIconPack(name) {
if (!_iconPacks[name]) {
console.warn(`[Theme] Unknown icon pack: ${name}`);
return;
}
_currentPack = name;
try { localStorage.setItem(ICON_PACK_KEY, name); } catch { /* */ }
}
/* -- Theme Editor UI -- */
/**
* Mount the theme editor into a container element.
* @param {HTMLElement} container
*/
export function mountEditor(container) {
container.innerHTML = '';
const current = getCurrentOverrides();
// Color pickers grouped
for (const group of TOKEN_GROUPS) {
const section = document.createElement('div');
section.className = 'theme-group';
const heading = document.createElement('h4');
heading.className = 'theme-group-title';
heading.textContent = t(group.label);
section.appendChild(heading);
for (const token of group.tokens) {
const row = document.createElement('div');
row.className = 'theme-row';
const label = document.createElement('label');
label.textContent = t(token.label);
label.className = 'theme-label';
const input = document.createElement('input');
input.type = token.type === 'color' ? 'color' : 'text';
input.className = 'theme-input';
input.value = normalizeColor(current[token.key] || '');
input.addEventListener('input', () => {
setToken(token.key, input.value);
});
row.appendChild(label);
row.appendChild(input);
section.appendChild(row);
}
container.appendChild(section);
}
// Raw CSS textarea (advanced)
const advSection = document.createElement('div');
advSection.className = 'theme-group';
const advTitle = document.createElement('h4');
advTitle.className = 'theme-group-title';
advTitle.textContent = t('theme.advanced');
advSection.appendChild(advTitle);
const textarea = document.createElement('textarea');
textarea.className = 'theme-raw-css';
textarea.rows = 6;
textarea.placeholder = '--my-var: #ff0000;\n--other: 12px;';
textarea.value = Object.entries(_userOverrides)
.filter(([k]) => !TOKEN_GROUPS.some(g => g.tokens.some(tk => tk.key === k)))
.map(([k, v]) => `${k}: ${v};`)
.join('\n');
advSection.appendChild(textarea);
const applyBtn = document.createElement('button');
applyBtn.className = 'btn btn-sm';
applyBtn.textContent = t('theme.applyRaw');
applyBtn.addEventListener('click', () => {
parseAndApplyRawCSS(textarea.value);
});
advSection.appendChild(applyBtn);
// Reset button
const resetBtn = document.createElement('button');
resetBtn.className = 'btn btn-sm btn-danger';
resetBtn.textContent = t('theme.reset');
resetBtn.addEventListener('click', () => {
resetToDefault();
mountEditor(container); // Re-render editor
});
advSection.appendChild(resetBtn);
container.appendChild(advSection);
}
/** Parse raw CSS var declarations from textarea */
function parseAndApplyRawCSS(raw) {
const lines = raw.split('\n');
for (const line of lines) {
const match = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/);
if (match) {
setToken(match[1], match[2]);
}
}
}
/** Normalize a CSS color to #hex for color picker inputs */
function normalizeColor(val) {
if (!val || val.includes('var(') || val.includes('rgba') || val.includes('color-mix')) {
return val; // Can't normalize complex values
}
// If it's already a hex, return as-is (truncate alpha channel for color picker)
if (/^#[0-9a-f]{6,8}$/i.test(val)) return val.slice(0, 7);
return val;
}