mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-12 15:42:00 +00:00
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:
437
web/js/core/actions.js
Normal file
437
web/js/core/actions.js
Normal 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
178
web/js/core/api.js
Normal 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
998
web/js/core/console-sse.js
Normal 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
97
web/js/core/dom.js
Normal 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
270
web/js/core/i18n.js
Normal 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
686
web/js/core/quickpanel.js
Normal 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();
|
||||
}
|
||||
103
web/js/core/resource-tracker.js
Normal file
103
web/js/core/resource-tracker.js
Normal 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
134
web/js/core/router.js
Normal 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');
|
||||
}
|
||||
}
|
||||
376
web/js/core/settings-config.js
Normal file
376
web/js/core/settings-config.js
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
131
web/js/core/sidebar-layout.js
Normal file
131
web/js/core/sidebar-layout.js
Normal 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
278
web/js/core/theme.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user