Add RLUtils class for managing RL/AI dashboard endpoints

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

721
web/js/app.js Normal file
View File

@@ -0,0 +1,721 @@
/**
* app.js — SPA bootstrap.
* Initializes core modules, registers routes, starts the router.
* Wires shell UI: console, quickpanel, actions, settings, launcher, pollers.
*/
import * as router from './core/router.js';
import * as i18n from './core/i18n.js';
import * as theme from './core/theme.js';
import { api, Poller } from './core/api.js';
import { $, el, setText, toast } from './core/dom.js';
import * as consoleSSE from './core/console-sse.js';
import * as quickpanel from './core/quickpanel.js';
import * as actions from './core/actions.js';
import * as settingsConfig from './core/settings-config.js';
/* =========================================
* 1) Initialize core modules
* ========================================= */
// Theme: apply saved CSS vars immediately (no flash)
theme.init();
// i18n: load translations, then boot UI
i18n.init().then(() => {
bootUI();
}).catch(err => {
console.error('[App] i18n init failed:', err);
bootUI(); // Boot anyway with fallback keys
});
function bootUI() {
// Runtime i18n wrappers for legacy hardcoded dialogs.
if (!window.__bjornDialogsPatched) {
const nativeConfirm = window.confirm.bind(window);
const nativePrompt = window.prompt.bind(window);
window.confirm = (msg) => nativeConfirm(i18n.trLoose(String(msg ?? '')));
window.prompt = (msg, def = '') => nativePrompt(i18n.trLoose(String(msg ?? '')), def);
window.__bjornDialogsPatched = true;
}
/* =========================================
* 2) Register all routes (lazy-loaded)
* ========================================= */
router.route('/dashboard', () => import('./pages/dashboard.js'));
router.route('/netkb', () => import('./pages/netkb.js'));
router.route('/network', () => import('./pages/network.js'));
router.route('/credentials', () => import('./pages/credentials.js'));
router.route('/vulnerabilities', () => import('./pages/vulnerabilities.js'));
router.route('/attacks', () => import('./pages/attacks.js'));
router.route('/scheduler', () => import('./pages/scheduler.js'));
router.route('/database', () => import('./pages/database.js'));
router.route('/files', () => import('./pages/files.js'));
router.route('/loot', () => import('./pages/loot.js'));
router.route('/actions', () => import('./pages/actions.js'));
router.route('/actions-studio', () => import('./pages/actions-studio.js'));
router.route('/backup', () => import('./pages/backup.js'));
router.route('/web-enum', () => import('./pages/web-enum.js'));
router.route('/zombieland', () => import('./pages/zombieland.js'));
router.route('/ai-dashboard', () => import('./pages/rl-dashboard.js?t=' + Date.now()));
router.route('/bjorn-debug', () => import('./pages/bjorn-debug.js'));
router.route('/bjorn', () => import('./pages/bjorn.js'));
// 404 fallback
router.setNotFound((container, path) => {
container.appendChild(
el('div', { class: 'not-found' }, [
el('h2', {}, [i18n.t('common.notFound')]),
el('p', {}, [`${i18n.t('common.notFound')}: ${path}`]),
el('a', { href: '#/dashboard' }, [i18n.t('nav.dashboard')])
])
);
});
/* =========================================
* 3) Mount language selector in topbar
* ========================================= */
const langContainer = $('#langSelect');
if (langContainer) {
i18n.mountLangSelector(langContainer);
}
/* =========================================
* 4) Initialize router (reads hash, loads first page)
* ========================================= */
const appContainer = $('#app');
router.init(appContainer);
window.addEventListener('i18n:changed', () => {
i18n.updateDOM(document);
router.reloadCurrent?.();
});
/* =========================================
* 5) Wire up topbar buttons
* ========================================= */
wireTopbar();
/* =========================================
* 6) Start global pollers (status, character, say)
* ========================================= */
ensureBjornProgress();
startGlobalPollers();
/* =========================================
* 7) Wire page launcher overlay
* ========================================= */
wireLauncher();
/* =========================================
* 8) Initialize shell modules
* ========================================= */
consoleSSE.init();
quickpanel.init();
actions.init();
/* =========================================
* 9) Wire bottombar extras (liveview, footer fit)
* ========================================= */
wireLiveview();
setupFooterFit();
/* =========================================
* 10) Wire settings modal
* ========================================= */
wireSettingsModal();
/* =========================================
* 11) Wire chip editor
* ========================================= */
wireChipEditor();
/* =========================================
* 12) Global toast bridge
* ========================================= */
window.toast = (msg, ms = 2600) => toast(msg, ms);
console.info('[App] Bjorn SPA initialized');
}
/* =========================================
* Global pollers — status bar updates
* OPTIMIZED: Staggered timings to reduce CPU load
* ========================================= */
function ensureBjornProgress() {
const host = document.querySelector('.status-left .status-text');
if (!host) return;
if (document.getElementById('bjornProgress')) return; // déjà là
const progress = el('div', {
id: 'bjornProgress',
class: 'bjorn-progress',
style: 'display:none;'
}, [
el('div', { class: 'bjorn-progress-bar' }),
el('span', { class: 'bjorn-progress-text' })
]);
host.appendChild(progress);
}
function startGlobalPollers() {
// Status (Toutes les 6s)
const statusPoller = new Poller(async () => {
try {
const data = await api.get('/bjorn_status', { timeout: 5000, retries: 0 });
const statusEl = $('#bjornStatus');
const status2El = $('#bjornStatus2');
const progressEl = $('#bjornProgress');
const progressBar = progressEl?.querySelector('.bjorn-progress-bar');
const progressText = progressEl?.querySelector('.bjorn-progress-text');
const imgEl = $('#bjornStatusImage');
if (statusEl && data.status) setText(statusEl, data.status);
if (status2El) {
if (data.status2) {
setText(status2El, data.status2);
status2El.style.display = '';
} else {
status2El.style.display = 'none';
}
}
// 🟢 PROGRESS — show only when actively running (1-100)
if (progressEl) {
const pct = Number(data.progress) || 0;
if (pct > 0) {
progressEl.style.display = '';
progressBar.style.setProperty('--progress', `${pct}%`);
progressText.textContent = `${pct}%`;
} else {
progressEl.style.display = 'none';
}
}
if (imgEl && data.image_path) {
imgEl.src = data.image_path + '?t=' + Date.now();
}
} catch (e) { }
}, 6000);
// Character (Toutes les 10s - C'est suffisant pour une icône)
const charPoller = new Poller(async () => {
try {
const imgEl = $('#bjorncharacter');
if (!imgEl) return;
const res = await fetch('/bjorn_character');
if (!res.ok) return;
const blob = await res.blob();
if (imgEl.src && imgEl.src.startsWith('blob:')) URL.revokeObjectURL(imgEl.src);
imgEl.src = URL.createObjectURL(blob);
} catch (e) { }
}, 10000);
// Say (Toutes les 8s)
const sayPoller = new Poller(async () => {
try {
const data = await api.get('/bjorn_say', { timeout: 5000, retries: 0 });
const sayEl = $('#bjornSay');
if (sayEl && data?.text) setText(sayEl, data.text);
} catch (e) { }
}, 8000);
statusPoller.start();
charPoller.start();
sayPoller.start();
}
/* =========================================
* Topbar wiring
* ========================================= */
function wireTopbar() {
// Logo -> dashboard
const logo = $('#logoBtn');
if (logo) {
logo.addEventListener('click', () => router.navigate('/dashboard'));
logo.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); router.navigate('/dashboard'); }
});
}
// Settings button
const settingsBtn = $('#openSettings');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => toggleSettings());
}
// Launcher button
const launcherBtn = $('#openLauncher');
if (launcherBtn) {
launcherBtn.addEventListener('click', () => toggleLauncher());
}
}
/* =========================================
* Liveview dropdown (BÉTON EDITION)
* Uses recursive setTimeout to prevent thread stacking
* ========================================= */
function wireLiveview() {
const character = $('#bjorncharacter');
const center = $('.status-center');
if (!character || !center) return;
const dropdown = el('div', { class: 'bjorn-dropdown' }, [
el('img', { id: 'screenImage_Home', src: '/web/screen.png', alt: 'Bjorn', style: 'cursor:pointer;max-width:200px;border-radius:6px' })
]);
center.appendChild(dropdown);
const liveImg = $('#screenImage_Home', dropdown);
let timer = null;
const LIVE_DELAY = 4000; // On passe à 4s pour matcher display.py
function updateLive() {
if (dropdown.style.display !== 'block') return; // Stop si caché
const n = new Image();
n.onload = () => {
liveImg.src = n.src;
// On ne planifie la suivante QUE quand celle-ci est affichée
timer = setTimeout(updateLive, LIVE_DELAY);
};
n.onerror = () => {
// En cas d'erreur, on attend un peu avant de réessayer
timer = setTimeout(updateLive, LIVE_DELAY * 2);
};
n.src = '/web/screen.png?t=' + Date.now();
}
const show = () => {
dropdown.style.display = 'block';
if (!timer) updateLive();
};
const hide = () => {
dropdown.style.display = 'none';
clearTimeout(timer);
timer = null;
};
// Events
character.addEventListener('mouseenter', show);
character.addEventListener('mouseleave', () => setTimeout(() => {
if (!dropdown.matches(':hover') && !character.matches(':hover')) hide();
}, 300));
character.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.style.display === 'block' ? hide() : show();
});
document.addEventListener('click', (ev) => {
if (!dropdown.contains(ev.target) && !character.contains(ev.target)) hide();
});
if (liveImg) {
liveImg.addEventListener('click', () => router.navigate('/bjorn'));
}
}
/* =========================================
* Footer text fitting (adaptive font size)
* ========================================= */
function setupFooterFit() {
function fitTextById(id, opts = {}) {
const el = document.getElementById(id);
if (!el) return;
const box = el.parentElement || el;
const max = opts.max || 12;
const min = opts.min || 7;
let size = max;
el.style.fontSize = size + 'px';
const maxH = parseFloat(getComputedStyle(el).maxHeight) || Infinity;
while ((el.scrollWidth > box.clientWidth || el.scrollHeight > maxH) && size > min) {
size--;
el.style.fontSize = size + 'px';
}
}
function runFooterFit() {
fitTextById('bjornStatus', { max: 12, min: 7 });
fitTextById('bjornSay', { max: 12, min: 7 });
fitTextById('bjornStatus2', { max: 12, min: 7 });
fitTextById('bjornProgress', { max: 11, min: 7 }); // 🟢
}
// Run on load & resize
window.addEventListener('load', runFooterFit);
window.addEventListener('resize', runFooterFit);
// Observe size/content changes
const left = document.querySelector('.status-left');
const right = document.querySelector('.status-right');
const ro = new ResizeObserver(runFooterFit);
if (left) ro.observe(left);
if (right) ro.observe(right);
['bjornStatus', 'bjornSay', 'bjornStatus2', 'bjornProgress'].forEach(id => {
const elem = document.getElementById(id);
if (!elem) return;
ro.observe(elem);
new MutationObserver(runFooterFit).observe(elem, {
childList: true,
characterData: true,
subtree: true
});
});
const imgs = [document.getElementById('bjornStatusImage'), document.getElementById('bjorncharacter')];
imgs.forEach(img => {
if (!img) return;
if (img.complete) runFooterFit();
else img.addEventListener('load', runFooterFit, { once: true });
});
// Initial run
runFooterFit();
}
/* =========================================
* Page launcher
* ========================================= */
const NAV_MODE_KEY = 'bjorn.navMode'; // 'rail' or 'grid'
function getNavMode() { return localStorage.getItem(NAV_MODE_KEY) || 'rail'; }
function setNavMode(mode) { localStorage.setItem(NAV_MODE_KEY, mode); }
const PAGES = [
{ path: '/dashboard', icon: 'home.png', label: 'nav.dashboard' },
{ path: '/bjorn', icon: 'bjorn_icon.png', label: 'nav.bjorn' },
{ path: '/netkb', icon: 'netkb.png', label: 'nav.netkb' },
{ path: '/network', icon: 'network.png', label: 'nav.network' },
{ path: '/credentials', icon: 'credentials.png', label: 'nav.credentials' },
{ path: '/vulnerabilities', icon: 'vulnerabilities.png', label: 'nav.vulnerabilities' },
{ path: '/attacks', icon: 'attacks.png', label: 'nav.attacks' },
{ path: '/scheduler', icon: 'scheduler.png', label: 'nav.scheduler' },
{ path: '/database', icon: 'database.png', label: 'nav.database' },
{ path: '/files', icon: 'files_explorer.png', label: 'nav.files' },
{ path: '/loot', icon: 'loot.png', label: 'nav.loot' },
{ path: '/actions', icon: 'actions_launcher.png', label: 'nav.actions' },
{ path: '/actions-studio', icon: 'actions_studio.png', label: 'nav.actionsStudio' },
{ path: '/backup', icon: 'backup_update.png', label: 'nav.backup' },
{ path: '/web-enum', icon: 'web_enum.png', label: 'nav.webEnum' },
{ path: '/zombieland', icon: 'zombieland.png', label: 'nav.zombieland' },
{ path: '/ai-dashboard', icon: 'ai_dashboard.png', label: 'nav.ai_dashboard' },
{ path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' },
];
function wireLauncher() {
const railOverlay = $('#launcher');
const gridOverlay = $('#navOverlay');
const navGrid = $('#navGrid');
if (!railOverlay) return;
// Build rail launcher
railOverlay.innerHTML = '';
const scroll = el('div', { class: 'launcher-scroll' });
for (const page of PAGES) {
const card = el('button', {
class: 'lbtn',
role: 'button',
tabindex: '0',
title: i18n.t(page.label),
onclick: () => {
router.navigate(page.path);
closeLauncher();
},
}, [
el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }),
el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]),
]);
scroll.appendChild(card);
}
railOverlay.appendChild(scroll);
// Build grid launcher
if (navGrid) {
navGrid.innerHTML = '';
for (const page of PAGES) {
const card = el('button', {
class: 'lbtn',
role: 'button',
tabindex: '0',
title: i18n.t(page.label),
onclick: () => {
router.navigate(page.path);
closeNavOverlay();
},
}, [
el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }),
el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]),
]);
navGrid.appendChild(card);
}
}
// Close rail on outside click
document.addEventListener('pointerdown', (e) => {
const btn = $('#openLauncher');
if (!railOverlay.classList.contains('show')) return;
if (railOverlay.contains(e.target)) return;
if (btn && btn.contains(e.target)) return;
closeLauncher();
});
// Close grid overlay on backdrop click
if (gridOverlay) {
gridOverlay.addEventListener('click', (e) => {
if (e.target === gridOverlay) closeNavOverlay();
});
}
}
function toggleLauncher() {
if (getNavMode() === 'grid') {
toggleNavOverlay();
} else {
const overlay = $('#launcher');
if (!overlay) return;
const isOpen = overlay.getAttribute('aria-hidden') !== 'false';
overlay.setAttribute('aria-hidden', String(!isOpen));
overlay.classList.toggle('show', isOpen);
}
}
function closeLauncher() {
const overlay = $('#launcher');
if (!overlay) return;
overlay.setAttribute('aria-hidden', 'true');
overlay.classList.remove('show');
}
function toggleNavOverlay() {
const overlay = $('#navOverlay');
if (!overlay) return;
const isOpen = overlay.classList.contains('show');
if (isOpen) {
overlay.classList.remove('show');
overlay.setAttribute('aria-hidden', 'true');
} else {
overlay.classList.add('show');
overlay.setAttribute('aria-hidden', 'false');
}
}
function closeNavOverlay() {
const overlay = $('#navOverlay');
if (!overlay) return;
overlay.classList.remove('show');
overlay.setAttribute('aria-hidden', 'true');
}
/* =========================================
* Settings modal (tabbed: General, Theme, Config)
* Uses the old-style modal-backdrop + modal with tabs
* ========================================= */
function wireSettingsModal() {
// Build modal content inside #settingsBackdrop
const backdrop = $('#settingsBackdrop');
if (!backdrop) return;
function buildSettings() {
backdrop.innerHTML = '';
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Settings' });
// Tabs navigation
const tabs = el('nav', { class: 'tabs', id: 'settingsTabs' });
const btnGeneral = el('button', { class: 'tabbtn active', 'data-tab': 'general' }, ['General']);
const btnTheme = el('button', { class: 'tabbtn', 'data-tab': 'theme' }, ['Theme']);
const btnConfig = el('button', { class: 'tabbtn', 'data-tab': 'config' }, ['Config']);
tabs.append(btnGeneral, btnTheme, btnConfig);
// General tab
const tabGeneral = el('section', { class: 'tabpanel', id: 'tab-general' });
tabGeneral.append(
el('h3', {}, [i18n.t('settings.general')]),
el('div', { class: 'row' }, [
el('label', {}, ['Notifications']),
el('div', { class: 'switch', id: 'switchNotifs' })
]),
el('div', { class: 'row' }, [
el('label', {}, ['Navigation Mode']),
el('select', { id: 'selectNavMode', class: 'select' }, [
el('option', { value: 'rail' }, ['Floating Bar']),
el('option', { value: 'grid' }, ['Grid Overlay']),
])
]),
el('div', { class: 'row' }, [
el('label', {}, [i18n.t('settings.language')]),
])
);
// Set current nav mode selection
const navModeSelect = tabGeneral.querySelector('#selectNavMode');
if (navModeSelect) {
navModeSelect.value = getNavMode();
navModeSelect.addEventListener('change', () => setNavMode(navModeSelect.value));
}
// Mount language selector inside general tab
const langRow = tabGeneral.querySelector('.row:last-child');
if (langRow) i18n.mountLangSelector(langRow);
// Theme tab
const tabTheme = el('section', { class: 'tabpanel', id: 'tab-theme', hidden: '' });
tabTheme.append(el('h3', {}, [i18n.t('settings.theme')]));
theme.mountEditor(tabTheme);
// Config tab
const tabConfig = el('section', { class: 'tabpanel', id: 'tab-config', hidden: '' }, [
el('div', { class: 'cfg-toolbar' }, [
el('button', { class: 'btn', id: 'cfgReload' }, [i18n.t('common.refresh')]),
el('button', { class: 'btn', id: 'cfgRestore' }, [i18n.t('common.reset')]),
el('button', { class: 'btn btn-primary', id: 'cfgSave' }, [i18n.t('common.save')]),
]),
el('div', { id: 'configFormHost', class: 'cfg-host' }),
]);
modal.append(tabs, tabGeneral, tabTheme, tabConfig);
backdrop.appendChild(modal);
const cfgHost = modal.querySelector('#configFormHost');
settingsConfig.mountConfig(cfgHost);
// Tab switching
tabs.addEventListener('click', (e) => {
const btn = e.target.closest('.tabbtn');
if (!btn) return;
const tabId = btn.dataset.tab;
tabs.querySelectorAll('.tabbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
modal.querySelectorAll('.tabpanel').forEach(p => p.hidden = true);
const panel = modal.querySelector(`#tab-${tabId}`);
if (panel) panel.hidden = false;
if (tabId === 'config') settingsConfig.loadConfig(cfgHost);
});
// Notifications switch
const notifSwitch = modal.querySelector('#switchNotifs');
if (notifSwitch) {
const notifOn = localStorage.getItem('bjorn.notifs') !== 'off';
if (notifOn) notifSwitch.classList.add('on');
notifSwitch.addEventListener('click', () => {
notifSwitch.classList.toggle('on');
localStorage.setItem('bjorn.notifs', notifSwitch.classList.contains('on') ? 'on' : 'off');
});
}
// Config actions
modal.querySelector('#cfgReload')?.addEventListener('click', () => settingsConfig.loadConfig(cfgHost));
modal.querySelector('#cfgSave')?.addEventListener('click', () => settingsConfig.saveConfig());
modal.querySelector('#cfgRestore')?.addEventListener('click', () => settingsConfig.restoreDefaults(cfgHost));
}
// Store build function for reuse
backdrop._buildSettings = buildSettings;
}
function toggleSettings() {
const backdrop = $('#settingsBackdrop');
if (!backdrop) return;
const isOpen = backdrop.style.display === 'flex';
if (isOpen) {
backdrop.style.display = 'none';
backdrop.setAttribute('aria-hidden', 'true');
} else {
if (backdrop._buildSettings) backdrop._buildSettings();
backdrop.style.display = 'flex';
backdrop.setAttribute('aria-hidden', 'false');
setTimeout(() => {
const m = backdrop.querySelector('.modal');
if (m) m.classList.add('show');
}, 0);
}
}
/* Close settings on backdrop click (persistent listener) */
document.addEventListener('click', (e) => {
const backdrop = $('#settingsBackdrop');
if (!backdrop || backdrop.style.display !== 'flex') return;
if (e.target === backdrop) toggleSettings();
});
/* Close settings on Escape (persistent listener) */
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const backdrop = $('#settingsBackdrop');
if (!backdrop || backdrop.style.display !== 'flex') return;
toggleSettings();
});
/* =========================================
* Chip Editor (global singleton)
* Wires the existing #chipEditBackdrop from HTML
* ========================================= */
function wireChipEditor() {
const backdrop = $('#chipEditBackdrop');
if (!backdrop || window.ChipsEditor) return;
const title = $('#chipEditTitle');
const label = $('#chipEditLabel');
const input = $('#chipEditInput');
const ta = $('#chipEditTextarea');
const btnSave = $('#chipEditSave');
const btnCancel = $('#chipEditCancel');
const btnClose = $('#chipEditClose');
if (!input || !ta || !btnSave) return;
let resolver = null;
function show() { backdrop.classList.add('show'); requestAnimationFrame(() => (input.offsetParent ? input : ta).focus()); }
function hide() { backdrop.classList.remove('show'); resolver = null; }
function currentValue() { return (input.offsetParent ? input.value : ta.value).trim(); }
function resolve(val) { if (resolver) { resolver(val); hide(); } }
function save() { resolve(currentValue()); }
function cancel() { resolve(null); }
btnSave.addEventListener('click', save);
btnCancel.addEventListener('click', cancel);
btnClose.addEventListener('click', cancel);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); });
document.addEventListener('keydown', (e) => {
if (!backdrop.classList.contains('show')) return;
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
if (e.key === 'Enter' && e.target.closest('#chipEditBackdrop') && e.target.id !== 'chipEditTextarea') {
e.preventDefault(); save();
}
});
window.ChipsEditor = {
open(opts = {}) {
const { value = '', title: ttl = 'Edit value', label: lab = 'Value', placeholder = '', multiline = false, maxLength, confirmLabel = 'Save' } = opts;
if (title) title.textContent = ttl;
if (label) label.textContent = lab;
if (btnSave) btnSave.textContent = confirmLabel;
if (multiline) {
ta.style.display = '';
input.style.display = 'none';
ta.value = value;
ta.placeholder = placeholder;
ta.removeAttribute('maxlength');
if (maxLength) ta.setAttribute('maxlength', String(maxLength));
} else {
input.style.display = '';
ta.style.display = 'none';
input.value = value;
input.placeholder = placeholder;
input.removeAttribute('maxlength');
if (maxLength) input.setAttribute('maxlength', String(maxLength));
}
show();
return new Promise(res => { resolver = res; });
}
};
}

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

41
web/js/pages/_stub.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Page module stub template.
* Copy this file and rename for each new page.
* Replace PAGE_NAME, endpoint, and build logic.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, setText, escapeHtml } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE_NAME = 'stub';
let tracker = null;
let poller = null;
export async function mount(container) {
tracker = new ResourceTracker(PAGE_NAME);
container.appendChild(el('div', { class: `${PAGE_NAME}-container` }, [
el('h2', { 'data-i18n': `nav.${PAGE_NAME}` }, [t(`nav.${PAGE_NAME}`)]),
el('div', { id: `${PAGE_NAME}-content` }, [t('common.loading')]),
]));
// Initial fetch
await refresh();
// Optional poller (visibility-aware)
// poller = new Poller(refresh, 10000);
// poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
}
async function refresh() {
// try {
// const data = await api.get('/endpoint', { timeout: 8000 });
// paint(data);
// } catch (err) { console.warn(`[${PAGE_NAME}]`, err.message); }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import { ResourceTracker } from '../core/resource-tracker.js';
import { el } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { mountStudioRuntime } from './actions-studio-runtime.js';
const PAGE = 'actions-studio';
let tracker = null;
let runtimeCleanup = null;
function studioTemplate() {
return `
<div id="app">
<header>
<div class="logo" aria-hidden="true"></div>
<h1>BJORN Studio</h1>
<div class="sp"></div>
<button class="btn icon" id="btnPal" title="Open actions/hosts panel" aria-controls="left">&#9776;</button>
<button class="btn icon" id="btnIns" title="Open inspector panel" aria-controls="right">&#9881;</button>
<button class="btn" id="btnAutoLayout" title="Auto-layout">&#9889; Auto-layout</button>
<button class="btn" id="btnRepel" title="Repel overlap">Repel</button>
<button class="btn primary" id="btnApply" title="Save and apply">Apply</button>
<button class="btn" id="btnHelp" title="Show shortcuts and gestures">Help</button>
<div class="kebab" style="position:relative">
<button class="btn icon" id="btnMenu" aria-haspopup="true">&#8942;</button>
<div class="menu" id="mainMenu" role="menu" aria-label="Actions" style="position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px;box-shadow:0 10px 32px rgba(0,0,0,.45);display:none;z-index:2400">
<div class="item" id="mAddHost" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Add host</div>
<div class="item" id="mAutoLayout" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Auto layout</div>
<div class="item" id="mRepel" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Repel overlap</div>
<div class="item" id="mFit" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Fit graph</div>
<div class="item" id="mHelp" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Help</div>
<div class="item" id="mSave" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Save to DB</div>
<div class="item" id="mImportdbActions" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Import actions DB</div>
<div class="item" id="mImportdbActionsStudio" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Import studio DB</div>
</div>
</div>
</header>
<main>
<aside id="left" aria-label="Palette">
<div class="studio-sidehead">
<div class="studio-sidehead-title">Palette</div>
<button class="btn icon studio-side-close" id="btnCloseLeft" type="button" aria-label="Close left panel">&times;</button>
</div>
<div class="tabs">
<div class="tab active" data-tab="actions">Actions</div>
<div class="tab" data-tab="hosts">Hosts</div>
</div>
<div class="tab-content active" id="tab-actions">
<div class="search-row">
<input class="search" id="filterActions" placeholder="Filter actions...">
<button class="search-clear" id="clearFilterActions" aria-label="Clear action filter">&times;</button>
</div>
<div class="palette-meta" id="actionsMeta">
<span class="pill"><span id="actionsTotalCount">0</span> total</span>
<span class="pill"><span id="actionsPlacedCount">0</span> placed</span>
</div>
<h2>Available actions</h2>
<div id="plist"></div>
</div>
<div class="tab-content" id="tab-hosts">
<div class="search-row">
<input class="search" id="filterHosts" placeholder="Filter host/IP/MAC...">
<button class="search-clear" id="clearFilterHosts" aria-label="Clear host filter">&times;</button>
</div>
<div class="palette-meta" id="hostsMeta">
<span class="pill"><span id="hostsTotalCount">0</span> total</span>
<span class="pill"><span id="hostsAliveCount">0</span> alive</span>
<span class="pill"><span id="hostsPlacedCount">0</span> placed</span>
</div>
<button class="btn" id="btnCreateHost" style="width:100%;margin-bottom:10px">Create test host</button>
<h2>Real hosts</h2>
<div id="realHosts"></div>
<h2>Test hosts</h2>
<div id="testHosts"></div>
</div>
</aside>
<section id="center" aria-label="Canvas">
<div id="bggrid"></div>
<div id="canvas" style="transform:translate(0px,0px) scale(1)">
<svg id="links" width="4000" height="3000" aria-label="Graph links"></svg>
<div id="nodes" aria-live="polite"></div>
</div>
<div id="controls">
<button class="ctrl" id="zIn" title="Zoom in" aria-label="Zoom in">+</button>
<button class="ctrl" id="zOut" title="Zoom out" aria-label="Zoom out">-</button>
<button class="ctrl" id="zFit" title="Fit to screen" aria-label="Fit graph">[]</button>
</div>
<div id="canvasHint" class="canvas-hint">
<strong>Tips</strong>
<span>Drag background to pan, mouse wheel/pinch to zoom, connect ports to link nodes.</span>
<button id="btnHideCanvasHint" class="btn icon" aria-label="Hide hint">&times;</button>
</div>
</section>
<aside id="right" aria-label="Inspector">
<div class="studio-sidehead">
<div class="studio-sidehead-title">Inspector</div>
<button class="btn icon studio-side-close" id="btnCloseRight" type="button" aria-label="Close right panel">&times;</button>
</div>
<div class="section" id="actionInspector">
<h3>Selected action</h3>
<div id="noSel" class="small">Select a node to edit it</div>
<div id="edit" style="display:none">
<label><span>b_class</span><input id="e_class" disabled></label>
<div class="form-row">
<label><span>b_module</span><input id="e_module"></label>
<label><span>b_status</span><input id="e_status"></label>
</div>
<div class="form-row">
<label><span>Type</span>
<select id="e_type"><option value="normal">normal</option><option value="global">global</option></select>
</label>
<label><span>Enabled</span>
<select id="e_enabled"><option value="1">Yes</option><option value="0">No</option></select>
</label>
</div>
<div class="form-row">
<label><span>Priority</span><input type="number" id="e_prio" min="1" max="100"></label>
<label><span>Timeout</span><input type="number" id="e_timeout"></label>
</div>
<div class="form-row">
<label><span>Max retries</span><input type="number" id="e_retry"></label>
<label><span>Cooldown (s)</span><input type="number" id="e_cool"></label>
</div>
<div class="form-row">
<label><span>Rate limit</span><input id="e_rate" placeholder="3/86400"></label>
<label><span>Port</span><input type="number" id="e_port" placeholder="22"></label>
</div>
<label><span>Services (CSV)</span><input id="e_services" placeholder="ssh, http, https"></label>
<label><span>Tags JSON</span><input id="e_tags" placeholder='["notif"]'></label>
<hr>
<h3>Trigger</h3>
<div class="form-row">
<label><span>Type</span>
<select id="t_type">
<option>on_start</option><option>on_new_host</option><option>on_host_alive</option><option>on_host_dead</option>
<option>on_join</option><option>on_leave</option><option>on_port_change</option><option>on_new_port</option>
<option>on_service</option><option>on_web_service</option><option>on_success</option><option>on_failure</option>
<option>on_cred_found</option><option>on_mac_is</option><option>on_essid_is</option><option>on_ip_is</option>
<option>on_has_cve</option><option>on_has_cpe</option><option>on_all</option><option>on_any</option><option>on_interval</option>
</select>
</label>
<label><span>Parameter</span><input id="t_param" placeholder="port / service / ActionName / JSON list" style="font-family:ui-monospace"></label>
</div>
<hr>
<h3>Requirements</h3>
<div class="row">
<label style="flex:1"><span>Mode</span>
<select id="r_mode"><option value="all">ALL (AND)</option><option value="any">ANY (OR)</option></select>
</label>
<button class="btn" id="r_add">+ Condition</button>
</div>
<div id="r_list" class="small"></div>
<div class="row" style="margin-top:.6rem">
<button class="btn" id="btnUpdateAction">Apply</button>
<button class="btn" id="btnDeleteNode">Remove from canvas</button>
</div>
</div>
</div>
<div class="section" id="hostInspector" style="display:none">
<h3>Selected host</h3>
<div class="form-row">
<label><span>MAC</span><input id="h_mac"></label>
<label><span>Hostname</span><input id="h_hostname"></label>
</div>
<div class="form-row">
<label><span>IP(s)</span><input id="h_ips" placeholder="192.168.1.10;192.168.1.11"></label>
<label><span>Ports</span><input id="h_ports" placeholder="22;80;443"></label>
</div>
<div class="form-row">
<label><span>Alive</span>
<select id="h_alive"><option value="1">Yes</option><option value="0">No</option></select>
</label>
<label><span>ESSID</span><input id="h_essid"></label>
</div>
<label><span>Services (JSON)</span><textarea id="h_services" placeholder='[{"port":22,"service":"ssh"},{"port":80,"service":"http"}]'></textarea></label>
<label><span>Vulns (CSV)</span><input id="h_vulns" placeholder="CVE-2023-..., CVE-2024-..."></label>
<label><span>Creds (JSON)</span><textarea id="h_creds" placeholder='[{"service":"ssh","user":"admin","password":"pass"}]'></textarea></label>
<div class="row" style="margin-top:.6rem">
<button class="btn" id="btnUpdateHost">Apply</button>
<button class="btn" id="btnDeleteHost">Delete from canvas</button>
</div>
</div>
</aside>
<button id="sideBackdrop" class="studio-side-backdrop" aria-hidden="true" aria-label="Close side panels"></button>
<div id="studioMobileDock" class="studio-mobile-dock" aria-label="Studio mobile controls">
<button class="btn" id="btnPalDock" aria-controls="left" title="Open palette">Palette</button>
<button class="btn" id="btnFitDock" title="Fit graph">Fit</button>
<div class="studio-mobile-stats"><span id="nodeCountMini">0</span>N | <span id="linkCountMini">0</span>L</div>
<button class="btn primary" id="btnApplyDock">Apply</button>
<button class="btn" id="btnInsDock" aria-controls="right" title="Open inspector">Inspect</button>
</div>
</main>
<footer>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:var(--ok)"></span> success</div>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:var(--bad)"></span> failure</div>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:#7aa7ff"></span> requires</div>
<div class="pill">Pinch/scroll = zoom, drag = pan, connect ports to create links</div>
<div class="pill"><span id="nodeCount">0</span> nodes, <span id="linkCount">0</span> links</div>
</footer>
</div>
<div class="edge-menu" id="edgeMenu">
<div class="edge-menu-item" data-action="edit">Edit...</div>
<div class="edge-menu-item" data-action="toggle-success">Success</div>
<div class="edge-menu-item" data-action="toggle-failure">Failure</div>
<div class="edge-menu-item" data-action="toggle-req">Requires</div>
<div class="edge-menu-item danger" data-action="delete">Delete</div>
</div>
<div class="modal" id="linkWizard" aria-hidden="true" aria-labelledby="linkWizardTitle" role="dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="linkWizardTitle">Link</h2>
<button class="modal-close" id="lwClose" aria-label="Close">x</button>
</div>
<div class="modal-body">
<div class="row" style="margin-bottom:6px">
<div class="pill">From: <b id="lwFromName">-</b></div>
<div class="pill">To: <b id="lwToName">-</b></div>
</div>
<p class="small" id="lwContext">Choose behavior (trigger or requirement). Presets adapt to node types.</p>
<hr>
<div class="form-row">
<label><span>Mode</span>
<select id="lwMode"><option value="trigger">Trigger</option><option value="requires">Requirement</option></select>
</label>
<label><span>Preset</span><select id="lwPreset"></select></label>
</div>
<div class="form-row" id="lwParamsRow">
<label><span>Param 1</span><input id="lwParam1" placeholder="ssh / 22 / CVE-..."></label>
<label><span>Param 2</span><input id="lwParam2" placeholder="optional"></label>
</div>
<div class="section" style="margin-top:10px">
<div class="row"><div class="pill">Preview:</div><code id="lwPreview">-</code></div>
</div>
<div class="row" style="margin-top:16px">
<button class="btn primary" id="lwCreate">Validate</button>
<button class="btn" id="lwCancel">Cancel</button>
</div>
</div>
</div>
</div>
<div class="modal" id="hostModal" aria-hidden="true" aria-labelledby="hostModalTitle" role="dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="hostModalTitle">Add test host</h2>
<button class="modal-close" onclick="closeHostModal()" aria-label="Close">x</button>
</div>
<div class="modal-body">
<label><span>MAC Address</span><input id="new_mac" placeholder="AA:BB:CC:DD:EE:FF"></label>
<label><span>Hostname</span><input id="new_hostname" placeholder="test-server-01"></label>
<label><span>IP Address(es)</span><input id="new_ips" placeholder="192.168.1.100;192.168.1.101"></label>
<label><span>Open Ports</span><input id="new_ports" placeholder="22;80;443;3306"></label>
<label><span>Services (JSON)</span>
<textarea id="new_services" placeholder='[{"port":22,"service":"ssh"},{"port":80,"service":"http"}]'>[{"port":22,"service":"ssh"}]</textarea>
</label>
<label><span>Vulnerabilities (CSV)</span><input id="new_vulns" placeholder="CVE-2023-1234, CVE-2024-5678"></label>
<label><span>Credentials (JSON)</span>
<textarea id="new_creds" placeholder='[{"service":"ssh","user":"admin","password":"password"}]'>[]</textarea>
</label>
<label><span>Alive</span>
<select id="new_alive"><option value="1">Yes</option><option value="0">No</option></select>
</label>
<div style="display:flex;gap:10px;margin-top:20px">
<button class="btn primary" onclick="createTestHost()">Create host</button>
<button class="btn" onclick="closeHostModal()">Cancel</button>
</div>
</div>
</div>
</div>
<div class="modal" id="helpModal" aria-hidden="true" aria-labelledby="helpModalTitle" role="dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="helpModalTitle">Studio shortcuts</h2>
<button class="modal-close" id="helpClose" aria-label="Close">x</button>
</div>
<div class="modal-body">
<div class="section">
<h3>Navigation</h3>
<div class="small">Mouse wheel / pinch: zoom</div>
<div class="small">Drag canvas background: pan</div>
<div class="small">Drag node: move node</div>
</div>
<div class="section">
<h3>Keyboard</h3>
<div class="small"><b>F</b>: fit graph to viewport</div>
<div class="small"><b>Ctrl/Cmd + S</b>: save to DB</div>
<div class="small"><b>Esc</b>: close menus / sidebars / modals</div>
<div class="small"><b>Delete</b>: delete selected node</div>
</div>
</div>
</div>
</div>
`;
}
export function mount(container) {
tracker = new ResourceTracker(PAGE);
const root = el('div', { class: 'studio-container studio-runtime-host' }, [
el('div', { class: 'studio-loading' }, [t('common.loading')]),
]);
container.appendChild(root);
try {
root.innerHTML = studioTemplate();
runtimeCleanup = mountStudioRuntime(root);
} catch (err) {
root.innerHTML = '';
root.appendChild(el('div', { class: 'card', style: 'margin:12px;padding:12px' }, [
el('h3', {}, [t('nav.actionsStudio')]),
el('p', {}, [`Failed to initialize studio: ${err.message}`]),
]));
}
}
export function unmount() {
if (typeof runtimeCleanup === 'function') {
try { runtimeCleanup(); } catch { /* noop */ }
}
runtimeCleanup = null;
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
}

817
web/js/pages/actions.js Normal file
View File

@@ -0,0 +1,817 @@
/**
* Actions page (SPA) — old actions_launcher parity.
* Sidebar (actions/arguments) + multi-console panes.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, $$, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'actions';
let tracker = null;
let root = null;
let sidebarLayoutCleanup = null;
let actions = [];
let activeActionId = null;
let panes = [null, null, null, null];
let split = 1;
let assignTargetPaneIndex = null;
let searchQuery = '';
let currentTab = 'actions';
const logsByAction = new Map(); // actionId -> string[]
const pollingTimers = new Map(); // actionId -> timeoutId
const autoClearPane = [false, false, false, false];
function tx(key, fallback) {
const v = t(key);
return v === key ? fallback : v;
}
function isMobile() {
return window.matchMedia('(max-width: 860px)').matches;
}
function q(sel, base = root) { return base?.querySelector(sel) || null; }
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
sidebarLayoutCleanup = initSharedSidebarLayout(root, {
sidebarSelector: '.al-sidebar',
mainSelector: '#actionsLauncher',
storageKey: 'sidebar:actions',
toggleLabel: tx('common.menu', 'Menu'),
});
bindStaticEvents();
enforceMobileOnePane();
await loadActions();
renderActionsList();
renderConsoles();
}
export function unmount() {
if (typeof sidebarLayoutCleanup === 'function') {
sidebarLayoutCleanup();
sidebarLayoutCleanup = null;
}
for (const tmr of pollingTimers.values()) clearTimeout(tmr);
pollingTimers.clear();
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
root = null;
actions = [];
activeActionId = null;
panes = [null, null, null, null];
split = 1;
assignTargetPaneIndex = null;
searchQuery = '';
currentTab = 'actions';
logsByAction.clear();
}
function buildShell() {
const sideTabs = el('div', { class: 'tabs-container' }, [
el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [tx('actions.tabs.actions', 'Actions')]),
el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [tx('actions.tabs.arguments', 'Arguments')]),
]);
const sideHeader = el('div', { class: 'sideheader' }, [
el('div', { class: 'al-side-meta' }, [
el('div', { class: 'sidetitle' }, [tx('nav.actions', 'Actions')]),
el('button', { class: 'al-btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [tx('common.hide', 'Hide')]),
]),
sideTabs,
el('div', { class: 'al-search' }, [
el('input', {
id: 'searchInput',
class: 'al-input',
type: 'text',
placeholder: tx('actions.searchPlaceholder', 'Search actions...'),
}),
]),
]);
const actionsSidebar = el('div', { id: 'tab-actions', class: 'sidebar-page' }, [
el('div', { id: 'actionsList', class: 'al-list' }),
]);
const argsSidebar = el('div', { id: 'tab-arguments', class: 'sidebar-page', style: 'display:none' }, [
el('div', { class: 'section' }, [
el('div', { class: 'h' }, [tx('actions.args.title', 'Arguments')]),
el('div', { class: 'sub' }, [tx('actions.args.subtitle', 'Auto-generated from action definitions')]),
]),
el('div', { id: 'argBuilder', class: 'builder' }),
el('div', { class: 'section' }, [
el('input', {
id: 'freeArgs',
class: 'ctl',
type: 'text',
placeholder: tx('actions.args.free', 'Additional arguments (e.g., --verbose --debug)'),
}),
]),
el('div', { id: 'presetChips', class: 'chips' }),
]);
const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar]);
const sidebarPanel = el('aside', { class: 'panel al-sidebar' }, [sideHeader, sideContent]);
const splitSeg = el('div', { class: 'seg', id: 'splitSeg' }, [
el('button', { type: 'button', 'data-split': '1', class: 'active' }, ['1']),
el('button', { type: 'button', 'data-split': '2' }, ['2']),
el('button', { type: 'button', 'data-split': '3' }, ['3']),
el('button', { type: 'button', 'data-split': '4' }, ['4']),
]);
const toolbar = el('div', { class: 'toolbar2' }, [
el('div', { class: 'spacer' }),
splitSeg,
]);
const multiConsole = el('div', { class: 'multiConsole split-1', id: 'multiConsole' });
const centerPanel = el('section', { class: 'center panel' }, [toolbar, multiConsole]);
return el('div', { class: 'actions-container page-with-sidebar' }, [
sidebarPanel,
el('main', { id: 'actionsLauncher' }, [centerPanel]),
]);
}
function bindStaticEvents() {
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
if (tabActions) tracker.trackEventListener(tabActions, 'click', () => switchTab('actions'));
if (tabArgs) tracker.trackEventListener(tabArgs, 'click', () => switchTab('arguments'));
const searchInput = q('#searchInput');
if (searchInput) {
tracker.trackEventListener(searchInput, 'input', () => {
searchQuery = String(searchInput.value || '').trim().toLowerCase();
renderActionsList();
});
}
$$('#splitSeg button', root).forEach((btn) => {
tracker.trackEventListener(btn, 'click', () => {
if (isMobile()) {
enforceMobileOnePane();
return;
}
split = Number(btn.dataset.split || '1');
$$('#splitSeg button', root).forEach((b) => b.classList.toggle('active', b === btn));
renderConsoles();
});
});
tracker.trackEventListener(window, 'resize', onResizeDebounced);
}
function onResizeDebounced() {
clearTimeout(onResizeDebounced._t);
onResizeDebounced._t = setTimeout(() => {
enforceMobileOnePane();
renderConsoles();
}, 120);
}
function switchTab(tab) {
currentTab = tab;
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
const actionsPane = q('#tab-actions');
const argsPane = q('#tab-arguments');
if (tabActions) tabActions.classList.toggle('active', tab === 'actions');
if (tabArgs) tabArgs.classList.toggle('active', tab === 'arguments');
if (actionsPane) actionsPane.style.display = tab === 'actions' ? '' : 'none';
if (argsPane) argsPane.style.display = tab === 'arguments' ? '' : 'none';
}
function enforceMobileOnePane() {
if (!isMobile()) {
$$('#splitSeg button', root).forEach((btn) => {
btn.disabled = false;
btn.style.opacity = '';
btn.style.pointerEvents = '';
});
return;
}
split = 1;
if (!panes[0] && activeActionId) panes[0] = activeActionId;
for (let i = 1; i < panes.length; i++) panes[i] = null;
$$('#splitSeg button', root).forEach((btn) => {
btn.classList.toggle('active', btn.dataset.split === '1');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.pointerEvents = 'none';
});
}
async function loadActions() {
try {
const response = await api.get('/list_scripts', { timeout: 12000, retries: 1 });
const list = Array.isArray(response?.data) ? response.data : [];
const prev = new Map(actions.map((a) => [a.id, a.status]));
actions = list.map((raw) => normalizeAction(raw));
actions.forEach((a) => {
a.status = prev.get(a.id) || (a.is_running ? 'running' : 'ready');
if (!logsByAction.has(a.id)) logsByAction.set(a.id, []);
});
if (activeActionId && !actions.some((a) => a.id === activeActionId)) {
activeActionId = null;
empty(q('#argBuilder'));
empty(q('#presetChips'));
}
} catch (err) {
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
actions = [];
}
}
function normalizeAction(raw) {
const id = raw.b_module || (raw.name ? raw.name.replace(/\.py$/, '') : 'unknown');
let args = raw.b_args ?? {};
if (typeof args === 'string') {
try { args = JSON.parse(args); } catch { args = {}; }
}
let examples = raw.b_examples;
if (typeof examples === 'string') {
try { examples = JSON.parse(examples); } catch { examples = []; }
}
if (!Array.isArray(examples)) examples = [];
return {
id,
name: raw.name || raw.b_class || raw.b_module || 'Unnamed',
module: raw.b_module || raw.module || id,
bClass: raw.b_class || id,
category: (raw.b_action || raw.category || 'normal').toLowerCase(),
description: raw.description || tx('actions.description', 'Description'),
args,
icon: raw.b_icon || `/actions_icons/${encodeURIComponent(raw.b_class || id)}.png`,
version: raw.b_version || '',
author: raw.b_author || '',
docsUrl: raw.b_docs_url || '',
examples,
path: raw.path || raw.module_path || raw.b_module || id,
is_running: !!raw.is_running,
status: raw.is_running ? 'running' : 'ready',
};
}
function renderActionsList() {
const container = q('#actionsList');
if (!container) return;
empty(container);
const filtered = actions.filter((a) => {
if (!searchQuery) return true;
const hay = `${a.name} ${a.description} ${a.module} ${a.id} ${a.author} ${a.category}`.toLowerCase();
return searchQuery.split(/\s+/).every((term) => hay.includes(term));
});
if (!filtered.length) {
container.appendChild(el('div', { class: 'sub' }, [tx('actions.noActions', 'No actions found')]));
return;
}
for (const a of filtered) {
const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, [
el('div', { class: 'ic' }, [
el('img', {
class: 'ic-img',
src: a.icon,
alt: '',
onerror: (e) => {
e.target.onerror = null;
e.target.src = '/actions/actions_icons/default.png';
},
}),
]),
el('div', {}, [
el('div', { class: 'name' }, [a.name]),
el('div', { class: 'desc' }, [a.description]),
]),
el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]),
]);
tracker.trackEventListener(row, 'click', () => onActionSelected(a.id));
tracker.trackEventListener(row, 'dragstart', (ev) => {
ev.dataTransfer?.setData('text/plain', a.id);
});
container.appendChild(row);
}
}
function statusChipClass(status) {
if (status === 'running') return 'run';
if (status === 'success') return 'ok';
if (status === 'error') return 'err';
return '';
}
function statusChipText(status) {
if (status === 'running') return tx('actions.running', 'Running');
if (status === 'success') return tx('common.success', 'Success');
if (status === 'error') return tx('common.error', 'Error');
return tx('common.ready', 'Ready');
}
function onActionSelected(actionId) {
activeActionId = actionId;
const action = actions.find((a) => a.id === actionId);
if (!action) return;
renderActionsList();
renderArguments(action);
if (assignTargetPaneIndex != null) {
panes[assignTargetPaneIndex] = actionId;
clearAssignTarget();
renderConsoles();
return;
}
const existing = panes.findIndex((id) => id === actionId);
if (existing >= 0) {
highlightPane(existing);
return;
}
const effectiveSplit = isMobile() ? 1 : split;
let target = panes.slice(0, effectiveSplit).findIndex((id) => !id);
if (target < 0) target = 0;
panes[target] = actionId;
renderConsoles();
}
function renderArguments(action) {
switchTab('arguments');
const builder = q('#argBuilder');
const chips = q('#presetChips');
if (!builder || !chips) return;
empty(builder);
empty(chips);
const metaBits = [];
if (action.version) metaBits.push(`v${action.version}`);
if (action.author) metaBits.push(`by ${action.author}`);
if (metaBits.length || action.docsUrl) {
const top = el('div', { style: 'display:flex;justify-content:space-between;gap:8px;align-items:center' }, [
el('div', { class: 'sub' }, [metaBits.join(' • ')]),
action.docsUrl
? el('a', { class: 'al-btn', href: action.docsUrl, target: '_blank', rel: 'noopener noreferrer' }, ['Docs'])
: null,
]);
builder.appendChild(top);
}
const entries = Object.entries(action.args || {});
if (!entries.length) {
builder.appendChild(el('div', { class: 'sub' }, [tx('actions.args.none', 'No configurable arguments')]));
}
for (const [key, cfgRaw] of entries) {
const cfg = cfgRaw && typeof cfgRaw === 'object' ? cfgRaw : { type: 'text', default: cfgRaw };
const field = el('div', { class: 'field' }, [
el('div', { class: 'label' }, [cfg.label || key]),
createArgControl(key, cfg),
cfg.help ? el('div', { class: 'sub' }, [cfg.help]) : null,
]);
builder.appendChild(field);
}
const presets = Array.isArray(action.examples) ? action.examples : [];
for (let i = 0; i < presets.length; i++) {
const p = presets[i];
const label = p.name || p.title || `Preset ${i + 1}`;
const btn = el('button', { class: 'chip2', type: 'button' }, [label]);
tracker.trackEventListener(btn, 'click', () => applyPreset(p));
chips.appendChild(btn);
}
}
function createArgControl(key, cfg) {
const tpe = cfg.type || 'text';
if (tpe === 'select') {
const sel = el('select', { class: 'select', 'data-arg': key });
const choices = Array.isArray(cfg.choices) ? cfg.choices : [];
for (const c of choices) {
const opt = el('option', { value: String(c) }, [String(c)]);
if (cfg.default != null && String(cfg.default) === String(c)) opt.selected = true;
sel.appendChild(opt);
}
return sel;
}
if (tpe === 'checkbox') {
const ctl = el('input', { type: 'checkbox', class: 'ctl', 'data-arg': key });
ctl.checked = !!cfg.default;
return ctl;
}
if (tpe === 'number') {
const attrs = {
type: 'number',
class: 'ctl',
'data-arg': key,
value: cfg.default != null ? String(cfg.default) : '',
};
if (cfg.min != null) attrs.min = String(cfg.min);
if (cfg.max != null) attrs.max = String(cfg.max);
if (cfg.step != null) attrs.step = String(cfg.step);
return el('input', attrs);
}
if (tpe === 'range' || tpe === 'slider') {
const min = cfg.min != null ? Number(cfg.min) : 0;
const max = cfg.max != null ? Number(cfg.max) : 100;
const val = cfg.default != null ? Number(cfg.default) : min;
const wrap = el('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center' });
const range = el('input', {
type: 'range',
class: 'range',
'data-arg': key,
min: String(min),
max: String(max),
step: String(cfg.step != null ? cfg.step : 1),
value: String(val),
});
const out = el('span', { class: 'sub' }, [String(val)]);
tracker.trackEventListener(range, 'input', () => { out.textContent = range.value; });
wrap.appendChild(range);
wrap.appendChild(out);
return wrap;
}
return el('input', {
type: 'text',
class: 'ctl',
'data-arg': key,
value: cfg.default != null ? String(cfg.default) : '',
placeholder: cfg.placeholder || '',
});
}
function applyPreset(preset) {
const builder = q('#argBuilder');
if (!builder) return;
for (const [k, v] of Object.entries(preset || {})) {
if (k === 'name' || k === 'title') continue;
const input = builder.querySelector(`[data-arg="${k}"]`);
if (!input) continue;
if (input.type === 'checkbox') input.checked = !!v;
else input.value = String(v ?? '');
}
toast(tx('actions.toast.presetApplied', 'Preset applied'), 1400, 'success');
}
function collectArguments() {
const args = [];
const builder = q('#argBuilder');
if (builder) {
const controls = $$('[data-arg]', builder);
controls.forEach((ctl) => {
const key = ctl.getAttribute('data-arg');
const flag = '--' + String(key).replace(/_/g, '-');
if (ctl.type === 'checkbox') {
if (ctl.checked) args.push(flag);
return;
}
const value = String(ctl.value ?? '').trim();
if (!value) return;
args.push(flag, value);
});
}
const free = String(q('#freeArgs')?.value || '').trim();
if (free) args.push(...free.split(/\s+/));
return args.join(' ');
}
function renderConsoles() {
const container = q('#multiConsole');
if (!container) return;
const effectiveSplit = isMobile() ? 1 : split;
container.className = `multiConsole split-${effectiveSplit}`;
container.style.setProperty('--rows', effectiveSplit === 4 ? '2' : '1');
empty(container);
for (let i = effectiveSplit; i < panes.length; i++) panes[i] = null;
for (let i = 0; i < effectiveSplit; i++) {
const actionId = panes[i];
const action = actionId ? actions.find((a) => a.id === actionId) : null;
const pane = el('div', { class: 'pane', 'data-index': String(i) });
const title = el('div', { class: 'paneTitle' }, [
el('span', { class: 'dot', style: `background:${statusDotColor(action?.status || 'ready')}` }),
action ? el('img', {
class: 'paneIcon',
src: action.icon,
alt: '',
onerror: (e) => {
e.target.onerror = null;
e.target.src = '/actions/actions_icons/default.png';
},
}) : null,
el('div', { class: 'titleBlock' }, [
el('div', { class: 'titleLine' }, [el('strong', {}, [action ? action.name : tx('actions.emptyPane', '— Empty Pane —')])]),
action ? el('div', { class: 'metaLine' }, [
action.version ? el('span', { class: 'chip' }, ['v' + action.version]) : null,
action.author ? el('span', { class: 'chip' }, ['by ' + action.author]) : null,
]) : null,
]),
]);
const paneBtns = el('div', { class: 'paneBtns' });
if (!action) {
const assignBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('actions.assign', 'Assign')]);
tracker.trackEventListener(assignBtn, 'click', () => setAssignTarget(i));
paneBtns.appendChild(assignBtn);
} else {
const runBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('common.run', 'Run')]);
tracker.trackEventListener(runBtn, 'click', () => runActionInPane(i));
const stopBtn = el('button', { class: 'al-btn warn', type: 'button' }, [tx('common.stop', 'Stop')]);
tracker.trackEventListener(stopBtn, 'click', () => stopActionInPane(i));
const clearBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('console.clear', 'Clear')]);
tracker.trackEventListener(clearBtn, 'click', () => clearActionLogs(action.id));
const exportBtn = el('button', { class: 'al-btn', type: 'button' }, ['⬇ Export']);
tracker.trackEventListener(exportBtn, 'click', () => exportActionLogs(action.id, action.name));
const autoBtn = el('button', { class: 'al-btn', type: 'button' }, [autoClearPane[i] ? 'Auto-clear ON' : 'Auto-clear OFF']);
if (autoClearPane[i]) autoBtn.classList.add('warn');
tracker.trackEventListener(autoBtn, 'click', () => {
autoClearPane[i] = !autoClearPane[i];
renderConsoles();
});
paneBtns.appendChild(runBtn);
paneBtns.appendChild(stopBtn);
paneBtns.appendChild(clearBtn);
paneBtns.appendChild(exportBtn);
paneBtns.appendChild(autoBtn);
}
const header = el('div', { class: 'paneHeader' }, [title, paneBtns]);
const log = el('div', { class: 'paneLog', id: `paneLog-${i}` });
pane.appendChild(header);
pane.appendChild(log);
container.appendChild(pane);
tracker.trackEventListener(pane, 'dragover', (e) => {
e.preventDefault();
pane.classList.add('paneHighlight');
});
tracker.trackEventListener(pane, 'dragleave', () => pane.classList.remove('paneHighlight'));
tracker.trackEventListener(pane, 'drop', (e) => {
e.preventDefault();
pane.classList.remove('paneHighlight');
const dropped = e.dataTransfer?.getData('text/plain');
if (!dropped) return;
panes[i] = dropped;
renderConsoles();
});
renderPaneLog(i, actionId);
}
}
function renderPaneLog(index, actionId) {
const logEl = q(`#paneLog-${index}`);
if (!logEl) return;
empty(logEl);
if (!actionId) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.empty', 'Select an action to see logs')]));
return;
}
const lines = logsByAction.get(actionId) || [];
if (!lines.length) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.waiting', 'Waiting for logs...')]));
return;
}
for (const line of lines) {
logEl.appendChild(el('div', { class: `logline ${logLineClass(line)}` }, [String(line)]));
}
logEl.scrollTop = logEl.scrollHeight;
}
function logLineClass(line) {
const l = String(line || '').toLowerCase();
if (l.includes('error') || l.includes('failed') || l.includes('traceback')) return 'err';
if (l.includes('warn')) return 'warn';
if (l.includes('success') || l.includes('done') || l.includes('complete')) return 'ok';
if (l.includes('info') || l.includes('start')) return 'info';
return 'dim';
}
function statusDotColor(status) {
if (status === 'running') return 'var(--acid)';
if (status === 'success') return 'var(--ok)';
if (status === 'error') return 'var(--danger)';
return 'var(--accent-2, #18f0ff)';
}
function setAssignTarget(index) {
assignTargetPaneIndex = index;
$$('.pane', root).forEach((p) => p.classList.remove('paneHighlight'));
q(`.pane[data-index="${index}"]`)?.classList.add('paneHighlight');
switchTab('actions');
}
function clearAssignTarget() {
assignTargetPaneIndex = null;
$$('.pane', root).forEach((p) => p.classList.remove('paneHighlight'));
}
function highlightPane(index) {
const pane = q(`.pane[data-index="${index}"]`);
if (!pane) return;
pane.classList.add('paneHighlight');
setTimeout(() => pane.classList.remove('paneHighlight'), 900);
}
async function runActionInPane(index) {
const actionId = panes[index] || activeActionId;
const action = actions.find((a) => a.id === actionId);
if (!action) {
toast(tx('actions.toast.selectActionFirst', 'Select an action first'), 1600, 'warning');
return;
}
if (!panes[index]) panes[index] = action.id;
if (autoClearPane[index]) clearActionLogs(action.id);
action.status = 'running';
renderActionsList();
renderConsoles();
const args = collectArguments();
appendActionLog(action.id, tx('actions.toast.startingAction', 'Starting {{name}}...').replace('{{name}}', action.name));
try {
const res = await api.post('/run_script', { script_name: action.module || action.id, args });
if (res.status !== 'success') throw new Error(res.message || 'Run failed');
startOutputPolling(action.id);
} catch (err) {
action.status = 'error';
appendActionLog(action.id, `Error: ${err.message}`);
renderActionsList();
renderConsoles();
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
}
}
async function stopActionInPane(index) {
const actionId = panes[index] || activeActionId;
const action = actions.find((a) => a.id === actionId);
if (!action) return;
try {
const res = await api.post('/stop_script', { script_name: action.path || action.module || action.id });
if (res.status !== 'success') throw new Error(res.message || 'Stop failed');
action.status = 'ready';
stopOutputPolling(action.id);
appendActionLog(action.id, tx('actions.toast.stoppedByUser', 'Stopped by user'));
renderActionsList();
renderConsoles();
} catch (err) {
toast(`${tx('actions.toast.failedToStop', 'Failed to stop')}: ${err.message}`, 2600, 'error');
}
}
function clearActionLogs(actionId) {
logsByAction.set(actionId, []);
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
const action = actions.find((a) => a.id === actionId);
if (action) {
api.post('/clear_script_output', { script_name: action.path || action.module || action.id }).catch(() => {});
}
}
function exportActionLogs(actionId, actionName = 'action') {
const logs = logsByAction.get(actionId) || [];
if (!logs.length) {
toast(tx('actions.toast.noLogsToExport', 'No logs to export'), 1600, 'warning');
return;
}
const blob = new Blob([logs.join('\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${actionName}_logs_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function appendActionLog(actionId, line) {
const list = logsByAction.get(actionId) || [];
list.push(line);
logsByAction.set(actionId, list);
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
}
function startOutputPolling(actionId) {
stopOutputPolling(actionId);
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const scriptPath = action.path || action.module || action.id;
const tick = async () => {
try {
const res = await api.get(`/get_script_output/${encodeURIComponent(scriptPath)}`, { timeout: 8000, retries: 0 });
if (res?.status !== 'success') throw new Error('Invalid output payload');
const data = res.data || {};
const output = Array.isArray(data.output) ? data.output : [];
logsByAction.set(actionId, output);
if (data.is_running) {
action.status = 'running';
renderActionsList();
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
const id = setTimeout(tick, 1000);
pollingTimers.set(actionId, id);
return;
}
if (data.last_error) {
action.status = 'error';
appendActionLog(actionId, `Error: ${data.last_error}`);
} else {
action.status = 'success';
appendActionLog(actionId, tx('actions.logs.completed', 'Script completed'));
}
stopOutputPolling(actionId);
renderActionsList();
renderConsoles();
} catch {
// Keep trying while action is expected running.
if (action.status === 'running') {
const id = setTimeout(tick, 1200);
pollingTimers.set(actionId, id);
}
}
};
tick();
}
function stopOutputPolling(actionId) {
const timer = pollingTimers.get(actionId);
if (timer) {
clearTimeout(timer);
pollingTimers.delete(actionId);
}
}

953
web/js/pages/attacks.js Normal file
View File

@@ -0,0 +1,953 @@
import { ResourceTracker } from '../core/resource-tracker.js';
import { el, toast } from '../core/dom.js';
import { t as i18nT } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'attacks';
let tracker = null;
let root = null;
let currentAttack = null;
let selectedSection = null;
let selectedImageScope = null;
let selectedActionName = null;
let selectedImages = new Set();
let editMode = false;
let imageCache = [];
let imageResolver = null;
let sortKey = 'name';
let sortDir = 1;
const iconCache = new Map();
let disposeSidebarLayout = null;
function q(sel, base = root) { return base?.querySelector(sel) || null; }
function qa(sel, base = root) { return Array.from(base?.querySelectorAll(sel) || []); }
function note(msg, ms = 2200, type = 'info') { toast(String(msg ?? ''), ms, type); }
function L(key, vars) { return i18nT(key, vars); }
function Lx(key, fallback, vars) {
const out = i18nT(key, vars);
return out && out !== key ? out : fallback;
}
function markup() {
return `
<div class="attacks-sidebar">
<div class="sidehead">
<div class="sidetitle">${L('attacks.sidebar.management')}</div>
<div class="spacer"></div>
<button class="btn" id="hideSidebar" data-hide-sidebar="1" type="button">${Lx('common.hide', 'Hide')}</button>
</div>
<div class="tabs-container">
<button class="tab-btn active" data-page="attacks">${L('attacks.tabs.attacks')}</button>
<button class="tab-btn" data-page="comments">${L('attacks.tabs.comments')}</button>
<button class="tab-btn" data-page="images">${L('attacks.tabs.images')}</button>
</div>
<div id="attacks-sidebar" class="sidebar-page" style="display:block">
<ul class="unified-list" id="attacks-list"></ul>
<div class="hero-btn">
<button class="btn" id="add-attack-btn">${L('attacks.btn.addAttack')}</button>
<button class="btn danger" id="remove-attack-btn">${L('attacks.btn.removeAttack')}</button>
<button class="btn danger" id="delete-action-btn">${L('attacks.btn.deleteAction')}</button>
<button class="btn" id="sync-missing-btn">${Lx('attacks.btn.syncMissing', 'Sync Missing')}</button>
<button class="btn danger" id="restore-default-actions-btn">${L('attacks.btn.restoreDefaultsBundle')}</button>
</div>
<div id="empty-attacks-hint" style="display:none;opacity:.8;margin-top:8px">${L('attacks.empty.noAttacks')}</div>
</div>
<div id="comments-sidebar" class="sidebar-page" style="display:none">
<ul class="unified-list" id="section-list"></ul>
<div class="hero-btn">
<button class="btn" id="add-section-btn">${L('attacks.btn.addSection')}</button>
<button class="btn danger" id="delete-section-btn" disabled>${L('attacks.btn.deleteSection')}</button>
<button class="btn danger" id="restore-default-btn">${L('attacks.btn.restoreDefault')}</button>
</div>
<div id="empty-comments-hint" style="display:none;opacity:.8;margin-top:8px">${L('attacks.empty.noComments')}</div>
</div>
<div id="images-sidebar" class="sidebar-page" style="display:none">
<h3 style="margin:8px 0">${L('attacks.section.characters')}</h3>
<ul class="unified-list" id="character-list"></ul>
<div class="chips" style="margin:8px 0 16px 0">
<button class="btn" id="create-character-btn">${L('attacks.btn.createCharacter')}</button>
<button class="btn danger" id="delete-character-btn">${L('attacks.btn.deleteCharacter')}</button>
</div>
<h3 style="margin:8px 0">${L('attacks.section.statusImages')}</h3>
<ul class="unified-list" id="action-list"></ul>
<h3 style="margin:8px 0">${L('attacks.section.staticImages')}</h3>
<ul class="unified-list" id="library-list"></ul>
<h3 style="margin:8px 0">${L('attacks.section.webImages')}</h3>
<ul class="unified-list" id="web-images-list"></ul>
<h3 style="margin:8px 0">${L('attacks.section.actionIcons')}</h3>
<ul class="unified-list" id="actions-icons-list"></ul>
</div>
</div>
<div class="attacks-main">
<div id="attacks-page" class="page-content active">
<div class="editor-textarea-container">
<div class="editor-header">
<h2 id="editor-title" style="margin:0">${L('attacks.editor.selectAttack')}</h2>
<div class="editor-buttons">
<button class="btn" id="save-attack-btn">${L('common.save')}</button>
<button class="btn" id="restore-attack-btn">${L('attacks.btn.restoreDefault')}</button>
</div>
</div>
<textarea id="editor-textarea" class="editor-textarea" disabled></textarea>
</div>
</div>
<div id="comments-page" class="page-content">
<div class="buttons-container">
<h2 id="section-title" style="margin:0 0 10px 0">${L('attacks.tabs.comments')}</h2>
<button class="btn" id="select-all-btn">${L('common.selectAll')}</button>
<button class="btn" id="save-comments-btn">${L('common.save')}</button>
</div>
<div class="comments-container">
<div class="comments-editor" id="comments-editor" contenteditable="true" data-placeholder="${L('attacks.comments.placeholder')}" role="textbox" aria-multiline="true"></div>
</div>
</div>
<div id="images-page" class="page-content">
<div class="actions-bar">
<span class="chip" id="edit-mode-toggle-btn">${L('attacks.images.enterEditMode')}</span>
<select id="sort-key" class="select">
<option value="name">${L('attacks.images.sortName')}</option>
<option value="dim">${L('attacks.images.sortDimensions')}</option>
</select>
<button id="sort-dir" class="sort-toggle">^</button>
<div class="range-wrap" title="${Lx('attacks.images.gridDensity', 'Grid density')}">
<span>${Lx('attacks.images.density', 'Density')}</span>
<input id="density" type="range" min="120" max="260" value="160" class="range">
</div>
<div class="field"><span class="icon">S</span><input id="search-input" class="input" placeholder="${L('attacks.images.search')}"></div>
<button id="rename-image-btn" class="edit-only">${L('attacks.images.rename')}</button>
<button id="replace-image-btn" class="edit-only">${L('attacks.images.replace')}</button>
<button id="resize-images-btn" class="edit-only">${L('attacks.images.resizeSelected')}</button>
<button id="add-characters-btn" class="status-only">${L('attacks.images.addCharacters')}</button>
<button id="delete-images-btn" class="edit-only danger">${L('attacks.images.deleteSelected')}</button>
<button id="add-status-image-btn">${L('attacks.images.addStatus')}</button>
<button id="add-static-image-btn">${L('attacks.images.addStatic')}</button>
<button id="add-web-image-btn">${L('attacks.images.addWeb')}</button>
<button id="add-icon-image-btn">${L('attacks.images.addIcon')}</button>
</div>
<div class="image-container" id="image-container"></div>
</div>
</div>`;
}
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function postJSON(url, body = {}) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
}
async function iconFor(name) {
if (iconCache.has(name)) return iconCache.get(name);
for (const url of [`/actions_icons/${encodeURIComponent(name)}.png`, `/get_status_icon?action=${encodeURIComponent(name)}`]) {
try {
const r = await fetch(url);
if (!r.ok) continue;
const b = await r.blob();
const obj = URL.createObjectURL(b);
iconCache.set(name, obj);
return obj;
} catch { }
}
return '/web/images/attack.png';
}
function iconCandidateURLs(actionName) {
return [
`/actions_icons/${encodeURIComponent(actionName)}.png`,
`/actions_icons/${encodeURIComponent(actionName)}.bmp`,
`/get_status_icon?action=${encodeURIComponent(actionName)}`,
];
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function makePlaceholderIconBlob(actionName) {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0b0e13';
ctx.fillRect(0, 0, size, size);
ctx.lineWidth = 8;
ctx.strokeStyle = '#59b6ff';
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 8, 0, Math.PI * 2);
ctx.stroke();
const initials = (actionName || 'A')
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.slice(0, 2)
.map((x) => x[0])
.join('')
.toUpperCase() || 'A';
ctx.fillStyle = '#59b6ff';
ctx.font = 'bold 56px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, size / 2, size / 2 + 4);
return new Promise((resolve) => canvas.toBlob((b) => resolve(b || new Blob([], { type: 'image/png' })), 'image/png'));
}
async function fetchActionIconBlob(actionName) {
for (const url of iconCandidateURLs(actionName)) {
try {
const r = await fetch(url, { cache: 'no-cache' });
if (r.ok) return await r.blob();
} catch { }
}
try {
const r = await fetch('/web/images/attack.png', { cache: 'no-cache' });
if (r.ok) return await r.blob();
} catch { }
return makePlaceholderIconBlob(actionName);
}
async function hasStatusImage(actionName) {
const p = `/images/status/${encodeURIComponent(actionName)}/${encodeURIComponent(actionName)}.bmp`;
try {
const r = await fetch(p, { cache: 'no-cache' });
return r.ok;
} catch {
return false;
}
}
async function actionHasCharacterImages(actionName) {
try {
const data = await getJSON('/get_action_images?action=' + encodeURIComponent(actionName));
const imgs = data?.images || [];
if (!Array.isArray(imgs)) return false;
const rx = new RegExp(`^${escapeRegExp(actionName)}\\d+\\.(bmp|png|jpe?g|gif|webp)$`, 'i');
return imgs.some((im) => {
const n = typeof im === 'string' ? im : (im.name || im.filename || '');
return rx.test(String(n));
});
} catch {
return false;
}
}
async function ensureStatusImageFromIcon(actionName) {
if (await hasStatusImage(actionName)) return false;
const blob = await fetchActionIconBlob(actionName);
const fd = new FormData();
fd.append('type', 'action');
fd.append('action_name', actionName);
fd.append('status_image', new File([blob], `${actionName}.bmp`, { type: 'image/bmp' }));
const r = await fetch('/upload_status_image', { method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'success') throw new Error(d.message || 'upload_status_image failed');
return true;
}
async function ensureAtLeastOneCharacterImageFromIcon(actionName) {
if (await actionHasCharacterImages(actionName)) return false;
const blob = await fetchActionIconBlob(actionName);
const fd = new FormData();
fd.append('action_name', actionName);
fd.append('character_images', new File([blob], `${actionName}1.png`, { type: blob.type || 'image/png' }));
const r = await fetch('/upload_character_images', { method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'success') throw new Error(d.message || 'upload_character_images failed');
return true;
}
async function ensureCommentsSection(sectionName, sectionsSet) {
if (sectionsSet.has(sectionName)) return false;
await postJSON('/save_comments', {
section: sectionName,
comments: [Lx('attacks.sync.defaultComment', 'Add comment for this action')],
});
sectionsSet.add(sectionName);
return true;
}
async function syncMissing() {
try {
const attacksResp = await getJSON('/get_attacks');
const attacks = Array.isArray(attacksResp) ? attacksResp : (Array.isArray(attacksResp?.attacks) ? attacksResp.attacks : []);
const names = attacks.map((a) => a?.name || a?.id).filter(Boolean);
if (!names.length) {
note(Lx('attacks.sync.none', 'No attacks to sync.'), 2200, 'warning');
return;
}
const sectionsResp = await getJSON('/get_sections');
const sectionsSet = new Set((sectionsResp?.sections || []).map((x) => String(x)));
let createdComments = 0;
let createdStatus = 0;
let createdChars = 0;
for (const name of names) {
if (await ensureCommentsSection(name, sectionsSet)) createdComments++;
if (await ensureStatusImageFromIcon(name)) createdStatus++;
if (await ensureAtLeastOneCharacterImageFromIcon(name)) createdChars++;
}
note(
Lx(
'attacks.sync.done',
`Sync done. New comments: ${createdComments}, status images: ${createdStatus}, character images: ${createdChars}.`,
{ comments: createdComments, status: createdStatus, characters: createdChars },
),
4200,
'success',
);
await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]);
if (selectedImageScope) await refreshScope();
} catch (e) {
note(`${Lx('attacks.sync.failed', 'Sync Missing failed')}: ${e.message}`, 3200, 'error');
}
}
async function loadAttacks() {
const list = q('#attacks-list');
const hint = q('#empty-attacks-hint');
if (!list || !hint) return;
list.innerHTML = '';
try {
const data = await getJSON('/get_attacks');
const attacks = (Array.isArray(data) ? data : (data.attacks || []))
.map((a) => ({ name: a.name || a.id || L('common.unknown'), enabled: Number(a.enabled ?? a.b_enabled ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
hint.style.display = attacks.length ? 'none' : 'block';
for (const a of attacks) {
const li = document.createElement('li');
li.className = 'card';
li.dataset.attackName = a.name;
const img = document.createElement('img');
iconFor(a.name).then((u) => { img.src = u; });
const span = document.createElement('span');
span.textContent = a.name;
const dot = document.createElement('button');
dot.className = 'enable-dot' + (a.enabled ? ' on' : '');
dot.type = 'button';
tracker.trackEventListener(dot, 'click', async (e) => {
e.stopPropagation();
const target = !dot.classList.contains('on');
dot.classList.toggle('on', target);
const d = await postJSON('/actions/set_enabled', { action_name: a.name, enabled: target ? 1 : 0 });
if (d.status !== 'success') dot.classList.toggle('on', !target);
});
tracker.trackEventListener(li, 'click', () => selectAttack(a.name, li));
li.append(img, span, dot);
list.appendChild(li);
}
} catch {
hint.style.display = 'block';
hint.textContent = L('attacks.errors.loadAttacks');
}
}
async function selectAttack(name, node) {
qa('#attacks-list .card').forEach((n) => n.classList.remove('selected'));
node?.classList.add('selected');
currentAttack = name;
q('#editor-title').textContent = name;
const ta = q('#editor-textarea');
ta.disabled = false;
const d = await getJSON('/get_attack_content?name=' + encodeURIComponent(name));
ta.value = d?.status === 'success' ? (d.content ?? '') : '';
}
function imageSort(list) {
const cmpName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) * sortDir;
const area = (x) => (x.width || 0) * (x.height || 0);
return [...list].sort(sortKey === 'name' ? cmpName : ((a, b) => ((area(a) - area(b)) * sortDir || cmpName(a, b))));
}
function syncImageModeClasses() {
if (!root) return;
root.classList.toggle('edit-mode', !!editMode);
root.classList.remove('status-mode', 'static-mode', 'web-mode', 'icons-mode');
if (selectedImageScope === 'action') root.classList.add('status-mode');
if (selectedImageScope === 'static') root.classList.add('static-mode');
if (selectedImageScope === 'web') root.classList.add('web-mode');
if (selectedImageScope === 'icons') root.classList.add('icons-mode');
}
function renderImages(items, resolver) {
imageCache = items.map((im) => ({ name: typeof im === 'string' ? im : (im.name || im.filename || ''), width: im.width, height: im.height }));
imageResolver = resolver;
const grid = q('#image-container');
const search = (q('#search-input')?.value || '').toLowerCase().trim();
grid.innerHTML = '';
imageSort(imageCache).filter((x) => !search || x.name.toLowerCase().includes(search)).forEach((im) => {
const tile = document.createElement('div');
tile.className = 'image-item';
tile.classList.toggle('selectable', !!editMode);
tile.dataset.imageName = im.name;
const img = document.createElement('img');
img.src = resolver(im.name);
const info = document.createElement('div');
info.className = 'image-info';
info.textContent = im.width && im.height ? `${im.name} (${im.width}x${im.height})` : im.name;
const ring = document.createElement('div');
ring.className = 'select-ring';
const tick = document.createElement('div');
tick.className = 'tick-overlay';
tick.textContent = 'OK';
tracker.trackEventListener(tile, 'click', () => {
if (!editMode) return;
tile.classList.toggle('selected');
if (tile.classList.contains('selected')) selectedImages.add(im.name);
else selectedImages.delete(im.name);
});
tile.append(img, info, ring, tick);
grid.appendChild(tile);
});
}
async function loadSections() {
const ul = q('#section-list');
const hint = q('#empty-comments-hint');
ul.innerHTML = '';
try {
const d = await getJSON('/get_sections');
const sections = (d.sections || []).slice().sort((a, b) => String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true }));
hint.style.display = sections.length ? 'none' : 'block';
for (const name of sections) {
const li = document.createElement('li');
li.className = 'card';
li.dataset.section = name;
const img = document.createElement('img');
iconFor(name).then((u) => { img.src = u; });
const span = document.createElement('span');
span.textContent = name;
tracker.trackEventListener(li, 'click', async () => {
qa('#section-list .card').forEach((n) => n.classList.remove('selected'));
li.classList.add('selected');
selectedSection = name;
q('#delete-section-btn').disabled = false;
q('#section-title').textContent = `${L('attacks.tabs.comments')} - ${name}`;
const c = await getJSON('/get_comments?section=' + encodeURIComponent(name));
const ce = q('#comments-editor');
ce.classList.remove('placeholder');
ce.innerHTML = '';
(c.comments || []).forEach((line) => {
const div = document.createElement('div');
div.className = 'comment-line';
div.textContent = line || '\u200b';
ce.appendChild(div);
});
});
li.append(img, span);
ul.appendChild(li);
}
} catch {
hint.style.display = 'block';
}
}
function addScopeCard(parent, type, name, imgSrc, onClick) {
const li = document.createElement('li');
li.className = 'card';
li.dataset.type = type;
li.dataset.name = name;
const img = document.createElement('img'); img.src = imgSrc;
const span = document.createElement('span'); span.textContent = name;
tracker.trackEventListener(li, 'click', async () => { selectScope(type, name); await onClick(); });
li.append(img, span);
parent.appendChild(li);
}
async function loadImageScopes() {
const actionList = q('#action-list'); actionList.innerHTML = '';
const staticList = q('#library-list'); staticList.innerHTML = '';
const webList = q('#web-images-list'); webList.innerHTML = '';
const iconList = q('#actions-icons-list'); iconList.innerHTML = '';
try {
const actions = await getJSON('/get_actions');
(actions.actions || []).forEach((a) => {
const li = document.createElement('li'); li.className = 'card'; li.dataset.type = 'action'; li.dataset.name = a.name;
const img = document.createElement('img'); iconFor(a.name).then((u) => { img.src = u; });
const span = document.createElement('span'); span.textContent = a.name;
tracker.trackEventListener(li, 'click', async () => {
selectScope('action', a.name);
const d = await getJSON('/get_action_images?action=' + encodeURIComponent(a.name));
if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(a.name)}/${encodeURIComponent(n)}`);
});
li.append(img, span);
actionList.appendChild(li);
});
addScopeCard(staticList, 'static', L('attacks.section.staticImages'), '/web/images/static_icon.png', async () => {
const d = await getJSON('/list_static_images_with_dimensions');
if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n));
});
addScopeCard(webList, 'web', L('attacks.section.webImages'), '/web/images/icon-192x192.png', async () => {
const d = await getJSON('/list_web_images');
if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n));
});
addScopeCard(iconList, 'icons', L('attacks.section.actionIcons'), '/web/images/attack.png', async () => {
const d = await getJSON('/list_actions_icons');
if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n));
});
} catch {
note(L('attacks.errors.loadImages'), 2600, 'error');
}
}
function selectScope(type, name) {
qa('#action-list .card, #library-list .card, #web-images-list .card, #actions-icons-list .card').forEach((n) => n.classList.remove('selected'));
qa(`[data-type="${type}"][data-name="${name}"]`).forEach((n) => n.classList.add('selected'));
selectedImageScope = type;
selectedActionName = type === 'action' ? name : null;
selectedImages.clear();
syncImageModeClasses();
}
async function refreshScope() {
if (selectedImageScope === 'action' && selectedActionName) {
const d = await getJSON('/get_action_images?action=' + encodeURIComponent(selectedActionName));
if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(selectedActionName)}/${encodeURIComponent(n)}`);
} else if (selectedImageScope === 'static') {
const d = await getJSON('/list_static_images_with_dimensions');
if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n));
} else if (selectedImageScope === 'web') {
const d = await getJSON('/list_web_images');
if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n));
} else if (selectedImageScope === 'icons') {
const d = await getJSON('/list_actions_icons');
if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n));
}
}
async function loadCharacters() {
const ul = q('#character-list');
if (!ul) return;
ul.innerHTML = '';
const d = await getJSON('/list_characters');
const current = d.current_character;
(d.characters || []).forEach((c) => {
const li = document.createElement('li'); li.className = 'card'; li.dataset.name = c.name;
const img = document.createElement('img'); img.src = '/get_character_icon?character=' + encodeURIComponent(c.name) + '&t=' + Date.now();
img.onerror = () => { img.src = '/web/images/default_character_icon.png'; };
const span = document.createElement('span'); span.textContent = c.name;
if (c.name === current) { const ck = document.createElement('span'); ck.textContent = L('common.yes'); li.appendChild(ck); }
tracker.trackEventListener(li, 'click', async () => {
if (!confirm(L('attacks.confirm.switchCharacter', { name: c.name }))) return;
const r = await postJSON('/switch_character', { character_name: c.name });
if (r.status === 'success') { note(L('attacks.toast.characterSwitched'), 1800, 'success'); loadCharacters(); }
});
li.append(img, span);
ul.appendChild(li);
});
}
function setPage(page) {
qa('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.page === page));
qa('.sidebar-page').forEach((s) => { s.style.display = 'none'; });
qa('.page-content').forEach((p) => p.classList.remove('active'));
const sidebar = q(`#${page}-sidebar`);
if (sidebar) sidebar.style.display = 'block';
q(`#${page}-page`)?.classList.add('active');
}
function bindTabs() {
qa('.tab-btn').forEach((btn) => tracker.trackEventListener(btn, 'click', async () => {
const page = btn.dataset.page;
setPage(page);
if (page === 'attacks') await loadAttacks();
if (page === 'comments') await loadSections();
if (page === 'images') await Promise.all([loadImageScopes(), loadCharacters()]);
}));
}
function bindActions() {
tracker.trackEventListener(q('#add-attack-btn'), 'click', async () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.py';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('attack_file', f);
const r = await fetch('/add_attack', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') {
note(L('attacks.toast.attackImported'), 1800, 'success');
await loadAttacks();
await syncMissing();
}
};
inp.click();
});
tracker.trackEventListener(q('#remove-attack-btn'), 'click', async () => {
if (!currentAttack) return;
if (!confirm(L('attacks.confirm.removeAttack', { name: currentAttack }))) return;
const d = await postJSON('/remove_attack', { name: currentAttack });
if (d.status === 'success') {
currentAttack = null;
q('#editor-textarea').value = '';
q('#editor-textarea').disabled = true;
q('#editor-title').textContent = L('attacks.editor.selectAttack');
loadAttacks();
}
});
tracker.trackEventListener(q('#delete-action-btn'), 'click', async () => {
const actionName = currentAttack || selectedActionName;
if (!actionName) return note(L('attacks.toast.selectAttackFirst'), 1800, 'warning');
if (!confirm(L('attacks.confirm.deleteAction', { name: actionName }))) return;
const d = await postJSON('/action/delete', { action_name: actionName });
if (d.status === 'success') {
if (currentAttack === actionName) {
currentAttack = null;
q('#editor-textarea').value = '';
q('#editor-textarea').disabled = true;
q('#editor-title').textContent = L('attacks.editor.selectAttack');
}
note(L('attacks.toast.actionDeleted'), 1800, 'success');
await Promise.all([loadAttacks(), loadImageScopes()]);
} else {
note(d.message || L('common.error'), 2200, 'error');
}
});
tracker.trackEventListener(q('#restore-default-actions-btn'), 'click', async () => {
if (!confirm(L('attacks.confirm.restoreDefaultsBundle'))) return;
const d = await postJSON('/actions/restore_defaults', {});
if (d.status === 'success') {
note(L('attacks.toast.defaultsRestored'), 2000, 'success');
currentAttack = null;
selectedImageScope = null;
selectedActionName = null;
selectedImages.clear();
syncImageModeClasses();
await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]);
} else {
note(d.message || L('common.error'), 2200, 'error');
}
});
tracker.trackEventListener(q('#sync-missing-btn'), 'click', async () => {
await syncMissing();
});
tracker.trackEventListener(q('#save-attack-btn'), 'click', async () => {
if (!currentAttack) return;
const d = await postJSON('/save_attack', { name: currentAttack, content: q('#editor-textarea').value });
if (d.status === 'success') note(L('common.saved'), 1500, 'success');
});
tracker.trackEventListener(q('#restore-attack-btn'), 'click', async () => {
if (!currentAttack) return;
if (!confirm(L('attacks.confirm.restoreAttack', { name: currentAttack }))) return;
const d = await postJSON('/restore_attack', { name: currentAttack });
if (d.status === 'success') selectAttack(currentAttack, q(`#attacks-list .card[data-attack-name="${currentAttack}"]`));
});
tracker.trackEventListener(q('#create-character-btn'), 'click', async () => {
const name = prompt(L('attacks.prompt.newCharacterName'));
if (!name) return;
const d = await postJSON('/create_character', { character_name: name });
if (d.status === 'success') { note(L('attacks.toast.characterCreated'), 1800, 'success'); loadCharacters(); }
});
tracker.trackEventListener(q('#delete-character-btn'), 'click', async () => {
const d = await getJSON('/list_characters');
const deletable = (d.characters || []).filter((x) => x.name !== 'BJORN').map((x) => x.name);
if (!deletable.length) return note(L('attacks.toast.noDeletableCharacters'), 1800, 'warning');
const name = prompt(L('attacks.prompt.characterToDelete') + '\n' + deletable.join('\n'));
if (!name || !deletable.includes(name)) return;
if (!confirm(L('attacks.confirm.deleteCharacter', { name }))) return;
const r = await postJSON('/delete_character', { character_name: name });
if (r.status === 'success') { note(L('attacks.toast.characterDeleted'), 1800, 'success'); loadCharacters(); }
});
tracker.trackEventListener(q('#add-section-btn'), 'click', async () => {
const name = prompt(L('attacks.prompt.newSectionName'));
if (!name) return;
const d = await postJSON('/save_comments', { section: name, comments: [] });
if (d.status === 'success') loadSections();
});
tracker.trackEventListener(q('#delete-section-btn'), 'click', async () => {
if (!selectedSection) return;
if (!confirm(L('attacks.confirm.deleteSection', { name: selectedSection }))) return;
const d = await postJSON('/delete_comment_section', { section: selectedSection });
if (d.status === 'success') {
selectedSection = null;
q('#comments-editor').innerHTML = '';
q('#section-title').textContent = L('attacks.tabs.comments');
loadSections();
}
});
tracker.trackEventListener(q('#restore-default-btn'), 'click', async () => {
if (!confirm(L('attacks.confirm.restoreDefaultComments'))) return;
const r = await fetch('/restore_default_comments', { method: 'POST' });
const d = await r.json();
if (d.status === 'success') { note(L('attacks.toast.commentsRestored'), 1800, 'success'); loadSections(); }
});
tracker.trackEventListener(q('#save-comments-btn'), 'click', async () => {
if (!selectedSection) return note(L('attacks.toast.selectSectionFirst'), 1800, 'warning');
const lines = qa('.comment-line', q('#comments-editor')).map((x) => x.textContent?.trim()).filter(Boolean);
const d = await postJSON('/save_comments', { section: selectedSection, comments: lines });
if (d.status === 'success') note(L('attacks.toast.commentsSaved'), 1600, 'success');
});
tracker.trackEventListener(q('#select-all-btn'), 'click', () => {
const ce = q('#comments-editor');
if (!ce) return;
ce.focus();
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.selectNodeContents(ce);
sel.removeAllRanges();
sel.addRange(range);
});
tracker.trackEventListener(q('#search-input'), 'input', () => renderImages(imageCache, imageResolver || (() => '')));
tracker.trackEventListener(q('#sort-key'), 'change', (e) => { sortKey = e.target.value; renderImages(imageCache, imageResolver || (() => '')); });
tracker.trackEventListener(q('#sort-dir'), 'click', (e) => { sortDir *= -1; e.target.textContent = sortDir === 1 ? '^' : 'v'; renderImages(imageCache, imageResolver || (() => '')); });
tracker.trackEventListener(q('#density'), 'input', (e) => {
const px = Number(e.target.value) || 160;
root?.style.setProperty('--tile-min', `${px}px`);
try { localStorage.setItem('attacks.tileMin', String(px)); } catch { }
});
tracker.trackEventListener(q('#edit-mode-toggle-btn'), 'click', () => {
editMode = !editMode;
syncImageModeClasses();
q('#edit-mode-toggle-btn').textContent = editMode ? L('attacks.images.exitEditMode') : L('attacks.images.enterEditMode');
if (!editMode) {
selectedImages.clear();
qa('.image-item.selected').forEach((x) => x.classList.remove('selected'));
}
renderImages(imageCache, imageResolver || (() => ''));
});
tracker.trackEventListener(q('#rename-image-btn'), 'click', async () => {
if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning');
const oldName = Array.from(selectedImages)[0];
const newName = prompt(L('attacks.prompt.newImageName'), oldName);
if (!newName || newName === oldName) return;
const type = selectedImageScope === 'action' ? 'image' : selectedImageScope;
const d = await postJSON('/rename_image', { type, action: selectedActionName, old_name: oldName, new_name: newName });
if (d.status === 'success') { selectedImages.clear(); refreshScope(); }
});
tracker.trackEventListener(q('#replace-image-btn'), 'click', async () => {
if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning');
const oldName = Array.from(selectedImages)[0];
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData();
fd.append('type', selectedImageScope);
fd.append('image_name', oldName);
if (selectedImageScope === 'action') fd.append('action', selectedActionName);
fd.append('new_image', f);
const r = await fetch('/replace_image', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') { selectedImages.clear(); refreshScope(); }
};
inp.click();
});
tracker.trackEventListener(q('#resize-images-btn'), 'click', async () => {
if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning');
const w = Number(prompt(L('attacks.prompt.resizeWidth'), '100'));
const h = Number(prompt(L('attacks.prompt.resizeHeight'), '100'));
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return;
const payload = {
type: selectedImageScope,
action: selectedActionName,
image_names: Array.from(selectedImages),
width: Math.round(w),
height: Math.round(h),
};
const d = await postJSON('/resize_images', payload);
if (d.status === 'success') {
note(L('attacks.toast.imagesResized'), 1800, 'success');
selectedImages.clear();
await refreshScope();
} else {
note(d.message || L('common.error'), 2200, 'error');
}
});
tracker.trackEventListener(q('#add-characters-btn'), 'click', async () => {
if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning');
const inp = document.createElement('input');
inp.type = 'file';
inp.multiple = true;
inp.accept = '.bmp,.jpg,.jpeg,.png';
inp.onchange = async () => {
const files = Array.from(inp.files || []);
if (!files.length) return;
const fd = new FormData();
fd.append('action_name', selectedActionName);
files.forEach((f) => fd.append('character_images', f));
const r = await fetch('/upload_character_images', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') {
note(L('attacks.toast.characterImagesUploaded'), 1800, 'success');
await refreshScope();
} else {
note(d.message || L('common.error'), 2200, 'error');
}
};
inp.click();
});
tracker.trackEventListener(q('#delete-images-btn'), 'click', async () => {
if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning');
if (!confirm(L('attacks.confirm.deleteSelectedImages'))) return;
const d = await postJSON('/delete_images', { type: selectedImageScope, action: selectedActionName, image_names: Array.from(selectedImages) });
if (d.status === 'success') { selectedImages.clear(); refreshScope(); }
});
tracker.trackEventListener(q('#add-status-image-btn'), 'click', async () => {
if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning');
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('type', 'action'); fd.append('action_name', selectedActionName); fd.append('status_image', f);
const r = await fetch('/upload_status_image', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') refreshScope();
};
inp.click();
});
tracker.trackEventListener(q('#add-static-image-btn'), 'click', async () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('static_image', f);
const r = await fetch('/upload_static_image', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') refreshScope();
};
inp.click();
});
tracker.trackEventListener(q('#add-web-image-btn'), 'click', async () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('web_image', f);
const r = await fetch('/upload_web_image', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') refreshScope();
};
inp.click();
});
tracker.trackEventListener(q('#add-icon-image-btn'), 'click', async () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('icon_image', f);
const r = await fetch('/upload_actions_icon', { method: 'POST', body: fd });
const d = await r.json();
if (d.status === 'success') refreshScope();
};
inp.click();
});
}
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = el('div', { class: 'attacks-container page-with-sidebar' });
root.innerHTML = markup();
container.appendChild(root);
q('.attacks-sidebar')?.classList.add('page-sidebar');
q('.attacks-main')?.classList.add('page-main');
disposeSidebarLayout = initSharedSidebarLayout(root, {
sidebarSelector: '.attacks-sidebar',
mainSelector: '.attacks-main',
storageKey: 'sidebar:attacks',
mobileBreakpoint: 900,
toggleLabel: Lx('common.menu', 'Menu'),
mobileDefaultOpen: true,
});
bindTabs();
bindActions();
syncImageModeClasses();
const density = q('#density');
if (density) {
let tile = Number(density.value) || 160;
try {
const saved = Number(localStorage.getItem('attacks.tileMin'));
if (Number.isFinite(saved) && saved >= 120 && saved <= 260) tile = saved;
} catch { }
density.value = String(tile);
root.style.setProperty('--tile-min', `${tile}px`);
}
const ce = q('#comments-editor');
if (ce && !ce.textContent.trim()) {
ce.classList.add('placeholder');
ce.textContent = ce.dataset.placeholder || L('attacks.comments.placeholder');
tracker.trackEventListener(ce, 'focus', () => {
if (ce.classList.contains('placeholder')) {
ce.classList.remove('placeholder');
ce.innerHTML = '<div class="comment-line"><br></div>';
}
});
}
await loadAttacks();
}
export function unmount() {
for (const v of iconCache.values()) {
if (typeof v === 'string' && v.startsWith('blob:')) URL.revokeObjectURL(v);
}
iconCache.clear();
selectedImages.clear();
if (disposeSidebarLayout) {
disposeSidebarLayout();
disposeSidebarLayout = null;
}
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
root = null;
}

459
web/js/pages/backup.js Normal file
View File

@@ -0,0 +1,459 @@
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'backup';
let tracker = null;
let disposeSidebarLayout = null;
let backups = [];
let currentSection = 'backup';
let pendingModalAction = null;
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const shell = buildShell();
container.appendChild(shell);
tracker.trackEventListener(window, 'keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.backup-sidebar',
mainSelector: '.backup-main',
storageKey: 'sidebar:backup',
toggleLabel: t('common.menu'),
});
wireEvents();
switchSection('backup');
await loadBackups();
}
export function unmount() {
if (disposeSidebarLayout) {
try { disposeSidebarLayout(); } catch { /* noop */ }
disposeSidebarLayout = null;
}
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
backups = [];
currentSection = 'backup';
pendingModalAction = null;
}
function buildShell() {
return el('div', { class: 'page-backup page-with-sidebar' }, [
el('aside', { class: 'backup-sidebar page-sidebar' }, [
el('div', { class: 'sidehead backup-sidehead' }, [
el('h3', { class: 'backup-side-title' }, [t('backup.title')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]),
]),
navItem('backup', '/web/images/backuprestore.png', t('backup.backupRestore')),
navItem('update', '/web/images/update.png', t('backup.update')),
]),
el('div', { class: 'backup-main page-main' }, [
buildBackupSection(),
buildUpdateSection(),
]),
buildOptionsModal(),
el('div', { id: 'backup-loading', class: 'backup-loading-overlay', style: 'display:none' }, [
el('div', { class: 'backup-spinner' }),
]),
]);
}
function navItem(key, icon, label) {
return el('button', {
type: 'button',
class: 'backup-nav-item',
'data-section': key,
onclick: () => switchSection(key),
}, [
el('img', { src: icon, alt: '', class: 'backup-nav-icon' }),
el('span', { class: 'backup-nav-label' }, [label]),
]);
}
function buildBackupSection() {
return el('section', { id: 'section-backup', class: 'backup-section' }, [
el('h2', { class: 'backup-title' }, [t('backup.backupRestore')]),
el('form', { id: 'backup-form', class: 'backup-form' }, [
el('label', { for: 'backup-desc-input', class: 'backup-label' }, [t('common.description')]),
el('div', { class: 'backup-form-row' }, [
el('input', {
id: 'backup-desc-input',
class: 'backup-input',
type: 'text',
placeholder: t('backup.descriptionPlaceholder'),
required: 'required',
}),
el('button', { type: 'submit', class: 'btn btn-primary' }, [t('backup.createBackup')]),
]),
]),
el('h3', { class: 'backup-subtitle' }, [t('backup.lastBackup')]),
el('div', { id: 'backup-table-wrap', class: 'backup-table-wrap' }, [
el('div', { class: 'page-loading' }, [t('common.loading')]),
]),
]);
}
function buildUpdateSection() {
return el('section', { id: 'section-update', class: 'backup-section', style: 'display:none' }, [
el('h2', { class: 'backup-title' }, [t('backup.update')]),
el('div', { id: 'update-version-info', class: 'backup-update-message' }, [
t('backup.checkUpdatesHint'),
]),
el('div', { class: 'backup-update-actions' }, [
el('button', { class: 'btn', id: 'btn-check-update', onclick: onCheckUpdate }, [t('backup.checkUpdates')]),
el('button', { class: 'btn btn-primary', id: 'btn-upgrade', onclick: onUpgrade }, [t('backup.installUpdate')]),
el('button', { class: 'btn btn-danger', id: 'btn-fresh', onclick: onFreshStart }, [t('backup.freshStart')]),
]),
]);
}
function buildOptionsModal() {
return el('div', {
id: 'backup-modal',
class: 'backup-modal-overlay',
'aria-hidden': 'true',
style: 'display:none',
onclick: (e) => {
if (e.target.id === 'backup-modal') closeModal();
},
}, [
el('div', { class: 'backup-modal' }, [
el('div', { class: 'backup-modal-head' }, [
el('h3', { id: 'modal-title', class: 'backup-modal-title' }, [t('common.options')]),
el('button', { class: 'btn btn-sm', onclick: closeModal, type: 'button' }, ['X']),
]),
el('p', { class: 'backup-modal-help' }, [t('backup.selectKeepFolders')]),
keepCheckbox('keep-data', t('backup.keepData')),
keepCheckbox('keep-resources', t('backup.keepResources')),
keepCheckbox('keep-actions', t('backup.keepActions')),
keepCheckbox('keep-config', t('backup.keepConfig')),
el('div', { class: 'backup-modal-actions' }, [
el('button', { class: 'btn', type: 'button', onclick: closeModal }, [t('common.cancel')]),
el('button', { class: 'btn btn-primary', type: 'button', onclick: onModalConfirm }, [t('common.confirm')]),
]),
]),
]);
}
function keepCheckbox(id, label) {
return el('label', { class: 'backup-keep' }, [
el('input', { id, type: 'checkbox' }),
el('span', {}, [label]),
]);
}
function wireEvents() {
const form = $('#backup-form');
if (form) {
tracker?.trackEventListener(form, 'submit', onCreateBackup);
}
}
function switchSection(section) {
currentSection = section;
const secBackup = $('#section-backup');
const secUpdate = $('#section-update');
if (secBackup) secBackup.style.display = section === 'backup' ? '' : 'none';
if (secUpdate) secUpdate.style.display = section === 'update' ? '' : 'none';
document.querySelectorAll('.backup-nav-item').forEach((item) => {
item.classList.toggle('active', item.getAttribute('data-section') === section);
});
if (section === 'update') {
onCheckUpdate();
}
}
function ensureOk(response, fallbackMessage) {
if (!response || typeof response !== 'object') {
throw new Error(fallbackMessage || t('common.error'));
}
if (response.status && response.status !== 'success') {
throw new Error(response.message || fallbackMessage || t('common.error'));
}
return response;
}
async function loadBackups() {
const wrap = $('#backup-table-wrap');
if (wrap) {
empty(wrap);
wrap.appendChild(el('div', { class: 'page-loading' }, [t('common.loading')]));
}
try {
const data = ensureOk(await api.post('/list_backups', {}), t('backup.failedLoadBackups'));
backups = Array.isArray(data.backups) ? data.backups : [];
renderBackupTable();
} catch (err) {
backups = [];
renderBackupTable();
toast(`${t('backup.failedLoadBackups')}: ${err.message}`, 3200, 'error');
}
}
function renderBackupTable() {
const wrap = $('#backup-table-wrap');
if (!wrap) return;
empty(wrap);
if (!backups.length) {
wrap.appendChild(el('div', { class: 'backup-empty' }, [t('backup.noBackupsCreateAbove')]));
return;
}
const table = el('table', { class: 'backup-table' }, [
el('thead', {}, [
el('tr', {}, [
el('th', {}, [t('common.date')]),
el('th', {}, [t('common.description')]),
el('th', {}, [t('common.actions')]),
]),
]),
el('tbody', {}, backups.map((b) => backupRow(b))),
]);
wrap.appendChild(table);
}
function backupRow(backup) {
const actions = [
el('button', { class: 'btn btn-sm', type: 'button', onclick: () => onRestoreBackup(backup.filename) }, [t('backup.restoreBackup')]),
];
if (!backup.is_default) {
actions.push(el('button', { class: 'btn btn-sm', type: 'button', onclick: () => onSetDefault(backup.filename) }, [t('backup.setDefault')]));
}
actions.push(el('button', { class: 'btn btn-sm btn-danger', type: 'button', onclick: () => onDeleteBackup(backup.filename) }, [t('common.delete')]));
return el('tr', {}, [
el('td', {}, [formatDate(backup.date)]),
el('td', {}, [
el('span', {}, [backup.description || backup.filename || t('backup.unnamedBackup')]),
backup.is_default ? el('span', { class: 'pill backup-default-pill' }, [t('common.default')]) : null,
backup.is_github ? el('span', { class: 'pill' }, [t('backup.github')]) : null,
backup.is_restore ? el('span', { class: 'pill' }, [t('backup.restorePoint')]) : null,
]),
el('td', {}, [el('div', { class: 'backup-row-actions' }, actions)]),
]);
}
async function onCreateBackup(event) {
event.preventDefault();
const input = $('#backup-desc-input');
const description = input ? input.value.trim() : '';
if (!description) {
toast(t('backup.enterDescription'), 2200, 'warning');
if (input) input.focus();
return;
}
showLoading();
try {
const res = ensureOk(await api.post('/create_backup', { description }), t('backup.failedCreate'));
toast(res.message || t('backup.createdSuccessfully'), 2600, 'success');
if (input) input.value = '';
await loadBackups();
} catch (err) {
toast(`${t('backup.failedCreate')}: ${err.message}`, 3200, 'error');
} finally {
hideLoading();
}
}
function onRestoreBackup(filename) {
pendingModalAction = { type: 'restore', filename };
openModal(t('backup.restoreOptions'));
}
async function onSetDefault(filename) {
showLoading();
try {
ensureOk(await api.post('/set_default_backup', { filename }), t('backup.failedSetDefault'));
toast(t('backup.defaultUpdated'), 2200, 'success');
await loadBackups();
} catch (err) {
toast(`${t('backup.failedSetDefault')}: ${err.message}`, 3200, 'error');
} finally {
hideLoading();
}
}
async function onDeleteBackup(filename) {
if (!confirm(t('common.confirmQuestion'))) {
return;
}
showLoading();
try {
const res = ensureOk(await api.post('/delete_backup', { filename }), t('backup.failedDelete'));
toast(res.message || t('backup.deleted'), 2200, 'success');
await loadBackups();
} catch (err) {
toast(`${t('backup.failedDelete')}: ${err.message}`, 3200, 'error');
} finally {
hideLoading();
}
}
async function onCheckUpdate() {
const infoEl = $('#update-version-info');
if (infoEl) infoEl.textContent = t('backup.checkingUpdates');
try {
const data = await api.get('/check_update');
if (!infoEl) return;
empty(infoEl);
infoEl.appendChild(el('div', { class: 'backup-version-lines' }, [
el('span', {}, [t('backup.currentVersion'), ': ', el('strong', {}, [String(data.current_version || t('common.unknown'))])]),
el('span', {}, [t('backup.latestVersion'), ': ', el('strong', {}, [String(data.latest_version || t('common.unknown'))])]),
data.update_available
? el('span', { class: 'backup-update-available' }, [t('backup.updateAvailable')])
: el('span', { class: 'backup-update-ok' }, [t('backup.upToDate')]),
]));
infoEl.classList.remove('fade-in');
void infoEl.offsetWidth;
infoEl.classList.add('fade-in');
} catch (err) {
if (infoEl) infoEl.textContent = `${t('backup.failedCheckUpdates')}: ${err.message}`;
toast(`${t('backup.failedCheckUpdates')}: ${err.message}`, 3200, 'error');
}
}
function onUpgrade() {
pendingModalAction = { type: 'update' };
openModal(t('backup.updateOptions'));
}
async function onFreshStart() {
if (!confirm(t('backup.confirmFreshStart'))) {
return;
}
showLoading();
try {
const res = ensureOk(await api.post('/update_application', { mode: 'fresh_start', keeps: [] }), t('backup.freshStartFailed'));
toast(res.message || t('backup.freshStartInitiated'), 3000, 'success');
} catch (err) {
toast(`${t('backup.freshStartFailed')}: ${err.message}`, 3200, 'error');
} finally {
hideLoading();
}
}
function openModal(title) {
const modal = $('#backup-modal');
const titleEl = $('#modal-title');
if (titleEl) titleEl.textContent = title || t('common.options');
['keep-data', 'keep-resources', 'keep-actions', 'keep-config'].forEach((id) => {
const cb = $(`#${id}`);
if (cb) cb.checked = false;
});
if (modal) modal.style.display = 'flex';
if (modal) modal.setAttribute('aria-hidden', 'false');
}
function closeModal() {
const modal = $('#backup-modal');
if (modal) modal.style.display = 'none';
if (modal) modal.setAttribute('aria-hidden', 'true');
pendingModalAction = null;
}
function selectedKeeps() {
const map = {
'keep-data': 'data',
'keep-resources': 'resources',
'keep-actions': 'actions',
'keep-config': 'config',
};
const keeps = [];
for (const [id, value] of Object.entries(map)) {
const cb = $(`#${id}`);
if (cb && cb.checked) keeps.push(value);
}
return keeps;
}
async function onModalConfirm() {
const action = pendingModalAction;
if (!action) return;
const keeps = selectedKeeps();
closeModal();
showLoading();
try {
if (action.type === 'restore') {
const mode = keeps.length ? 'selective_restore' : 'full_restore';
const res = ensureOk(await api.post('/restore_backup', {
filename: action.filename,
mode,
keeps,
}), t('backup.restoreBackup'));
toast(res.message || t('backup.restoreCompleted'), 3000, 'success');
await loadBackups();
return;
}
if (action.type === 'update') {
const res = ensureOk(await api.post('/update_application', {
mode: 'upgrade',
keeps,
}), t('backup.update'));
toast(res.message || t('backup.updateInitiated'), 3000, 'success');
}
} catch (err) {
toast(`${t('common.failed')}: ${err.message}`, 3500, 'error');
} finally {
hideLoading();
}
}
function showLoading() {
const overlay = $('#backup-loading');
if (overlay) overlay.style.display = 'flex';
}
function hideLoading() {
const overlay = $('#backup-loading');
if (overlay) overlay.style.display = 'none';
}
function formatDate(value) {
if (!value) return t('common.unknown');
if (typeof value === 'string') {
const normalized = value.replace(' ', 'T');
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleString();
}
return value;
}
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}

644
web/js/pages/bjorn-debug.js Normal file
View File

@@ -0,0 +1,644 @@
/**
* Bjorn Debug — Real-time process profiler.
* Shows CPU, RSS, FD, threads over time + per-thread / per-file tables.
* v2: rich thread info, line-level tracemalloc, open files, graph tooltip.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, setText, empty } from '../core/dom.js';
let tracker = null;
let snapshotPoller = null;
// Ring buffers for graph
const MAX_PTS = 200;
const history = { ts: [], cpu: [], rss: [], fd: [], threads: [], swap: [] };
// Canvas refs
let graphCanvas = null;
let graphCtx = null;
let graphRAF = null;
// Tooltip state
let hoverIndex = -1;
let tooltipEl = null;
// State
let latestSnapshot = null;
let isPaused = false;
/* ============================================================
* mount / unmount
* ============================================================ */
export async function mount(container) {
tracker = new ResourceTracker('bjorn-debug');
container.innerHTML = '';
container.appendChild(buildLayout());
graphCanvas = document.getElementById('debugGraph');
tooltipEl = document.getElementById('dbgTooltip');
if (graphCanvas) {
graphCtx = graphCanvas.getContext('2d');
resizeCanvas();
tracker.trackEventListener(window, 'resize', resizeCanvas);
tracker.trackEventListener(graphCanvas, 'mousemove', onGraphMouseMove);
tracker.trackEventListener(graphCanvas, 'mouseleave', onGraphMouseLeave);
}
// Seed with server history
try {
const h = await api.get('/api/debug/history');
if (h && h.history) {
for (const pt of h.history) {
pushPoint(pt.ts, pt.proc_cpu_pct, pt.rss_kb, pt.fd_open, pt.py_thread_count, pt.vm_swap_kb || 0);
}
}
} catch (e) { /* first load */ }
snapshotPoller = new Poller(fetchSnapshot, 2000);
snapshotPoller.start();
drawLoop();
}
export function unmount() {
if (snapshotPoller) { snapshotPoller.stop(); snapshotPoller = null; }
if (graphRAF) { cancelAnimationFrame(graphRAF); graphRAF = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
graphCanvas = null;
graphCtx = null;
tooltipEl = null;
latestSnapshot = null;
hoverIndex = -1;
for (const k of Object.keys(history)) history[k].length = 0;
}
/* ============================================================
* Data fetching
* ============================================================ */
async function fetchSnapshot() {
if (isPaused) return;
try {
const data = await api.get('/api/debug/snapshot', { timeout: 5000, retries: 0 });
latestSnapshot = data;
pushPoint(data.ts, data.proc_cpu_pct, data.rss_kb, data.fd_open, data.py_thread_count, data.vm_swap_kb || 0);
updateCards(data);
updateThreadTable(data);
updatePyThreadTable(data);
updateTracemallocByLine(data);
updateTracemallocByFile(data);
updateOpenFilesTable(data);
} catch (e) { /* skip */ }
}
function pushPoint(ts, cpu, rss, fd, threads, swap) {
history.ts.push(ts);
history.cpu.push(cpu);
history.rss.push(rss);
history.fd.push(fd);
history.threads.push(threads);
history.swap.push(swap);
if (history.ts.length > MAX_PTS) {
for (const k of Object.keys(history)) history[k].shift();
}
}
/* ============================================================
* Layout
* ============================================================ */
function buildLayout() {
const page = el('div', { class: 'dbg-page' });
// -- Header --
const header = el('div', { class: 'dbg-header' });
header.appendChild(el('h2', { class: 'dbg-title' }, ['Bjorn Debug']));
const controls = el('div', { class: 'dbg-controls' });
const pauseBtn = el('button', { class: 'btn dbg-btn', id: 'dbgPause' }, ['Pause']);
pauseBtn.addEventListener('click', () => {
isPaused = !isPaused;
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', isPaused);
});
const gcBtn = el('button', { class: 'btn dbg-btn', id: 'dbgGC' }, ['Force GC']);
gcBtn.addEventListener('click', async () => {
try {
const r = await api.post('/api/debug/gc/collect', {});
if (window.toast) window.toast(`GC collected ${r.collected} objects`);
} catch (e) { if (window.toast) window.toast('GC failed'); }
});
const tmBtn = el('button', { class: 'btn dbg-btn', id: 'dbgTracemalloc' }, ['tracemalloc: ?']);
tmBtn.addEventListener('click', async () => {
const tracing = latestSnapshot?.tracemalloc_active;
try {
const r = await api.post('/api/debug/tracemalloc', { action: tracing ? 'stop' : 'start' });
tmBtn.textContent = `tracemalloc: ${r.tracing ? 'ON' : 'OFF'}`;
tmBtn.classList.toggle('active', r.tracing);
} catch (e) { if (window.toast) window.toast('tracemalloc toggle failed'); }
});
controls.append(pauseBtn, gcBtn, tmBtn);
header.appendChild(controls);
page.appendChild(header);
// -- KPI cards --
const cards = el('div', { class: 'dbg-cards', id: 'dbgCards' });
for (const cd of [
{ id: 'cardCPU', label: 'CPU %', value: '--' },
{ id: 'cardRSS', label: 'RSS (MB)', value: '--' },
{ id: 'cardSwap', label: 'Swap (MB)', value: '--' },
{ id: 'cardFD', label: 'Open FDs', value: '--' },
{ id: 'cardThreads', label: 'Threads', value: '--' },
{ id: 'cardPeak', label: 'RSS Peak (MB)', value: '--' },
]) {
const c = el('div', { class: 'dbg-card', id: cd.id });
c.appendChild(el('div', { class: 'dbg-card-value' }, [cd.value]));
c.appendChild(el('div', { class: 'dbg-card-label' }, [cd.label]));
cards.appendChild(c);
}
page.appendChild(cards);
// -- Graph with tooltip --
const graphWrap = el('div', { class: 'dbg-graph-wrap' });
const legend = el('div', { class: 'dbg-legend' });
for (const li of [
{ color: '#00d4ff', label: 'CPU %' },
{ color: '#00ff6a', label: 'RSS (MB)' },
{ color: '#ff4169', label: 'FDs' },
{ color: '#ffaa00', label: 'Threads' },
{ color: '#b44dff', label: 'Swap (MB)' },
]) {
const item = el('span', { class: 'dbg-legend-item' });
item.appendChild(el('span', { class: 'dbg-legend-dot', style: `background:${li.color}` }));
item.appendChild(document.createTextNode(li.label));
legend.appendChild(item);
}
graphWrap.appendChild(legend);
const canvasContainer = el('div', { class: 'dbg-canvas-container' });
canvasContainer.appendChild(el('canvas', { id: 'debugGraph', class: 'dbg-canvas' }));
canvasContainer.appendChild(el('div', { id: 'dbgTooltip', class: 'dbg-tooltip' }));
graphWrap.appendChild(canvasContainer);
page.appendChild(graphWrap);
// -- Tables --
const tables = el('div', { class: 'dbg-tables' });
// 1. Kernel threads (with Python mapping)
tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Kernel Threads (CPU %) — mapped to Python']));
tables.appendChild(makeTable('threadTable', 'threadBody',
['TID', 'Kernel', 'Python Name', 'Target / Current', 'State', 'CPU %', 'Bar']));
// 2. Python threads (rich)
tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Python Threads — Stack Trace']));
tables.appendChild(makeTable('pyThreadTable', 'pyThreadBody',
['Name', 'Target Function', 'Source File', 'Current Frame', 'Daemon', 'Alive']));
// 3. tracemalloc by LINE (the leak finder)
tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Top Allocations by Line']));
const tmInfo = el('div', { class: 'dbg-tm-info', id: 'tmInfo' }, ['tracemalloc not active — click the button to start']);
tables.appendChild(tmInfo);
tables.appendChild(makeTable('tmLineTable', 'tmLineBody',
['File', 'Line', 'Size (KB)', 'Count', 'Bar']));
// 4. tracemalloc by FILE (overview)
tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Aggregated by File']));
tables.appendChild(makeTable('tmFileTable', 'tmFileBody',
['File', 'Size (KB)', 'Count', 'Bar']));
// 5. Open file descriptors
tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Open File Descriptors']));
tables.appendChild(makeTable('fdTable', 'fdBody',
['Target', 'Type', 'Count', 'FDs', 'Bar']));
page.appendChild(tables);
// CSS
const style = document.createElement('style');
style.textContent = SCOPED_CSS;
page.appendChild(style);
return page;
}
function makeTable(tableId, bodyId, headers) {
const wrap = el('div', { class: 'dbg-table-wrap' });
const table = el('table', { class: 'dbg-table', id: tableId });
table.appendChild(el('thead', {}, [
el('tr', {}, headers.map(h => el('th', {}, [h])))
]));
table.appendChild(el('tbody', { id: bodyId }));
wrap.appendChild(table);
return wrap;
}
/* ============================================================
* Card updates
* ============================================================ */
function updateCards(d) {
setCardVal('cardCPU', d.proc_cpu_pct.toFixed(1), d.proc_cpu_pct > 80 ? 'hot' : d.proc_cpu_pct > 40 ? 'warm' : '');
setCardVal('cardRSS', (d.rss_kb / 1024).toFixed(1), d.rss_kb > 400000 ? 'hot' : d.rss_kb > 200000 ? 'warm' : '');
setCardVal('cardSwap', ((d.vm_swap_kb || 0) / 1024).toFixed(1), d.vm_swap_kb > 50000 ? 'hot' : d.vm_swap_kb > 10000 ? 'warm' : '');
setCardVal('cardFD', d.fd_open, d.fd_open > 500 ? 'hot' : d.fd_open > 200 ? 'warm' : '');
setCardVal('cardThreads', `${d.py_thread_count} / ${d.kernel_threads}`, d.py_thread_count > 50 ? 'hot' : d.py_thread_count > 20 ? 'warm' : '');
setCardVal('cardPeak', ((d.vm_peak_kb || 0) / 1024).toFixed(1), '');
const tmBtn = document.getElementById('dbgTracemalloc');
if (tmBtn) {
tmBtn.textContent = `tracemalloc: ${d.tracemalloc_active ? 'ON' : 'OFF'}`;
tmBtn.classList.toggle('active', d.tracemalloc_active);
}
}
function setCardVal(id, val, level) {
const card = document.getElementById(id);
if (!card) return;
const valEl = card.querySelector('.dbg-card-value');
if (valEl) valEl.textContent = val;
card.classList.remove('hot', 'warm');
if (level) card.classList.add(level);
}
/* ============================================================
* Tables
* ============================================================ */
function updateThreadTable(d) {
const body = document.getElementById('threadBody');
if (!body || !d.threads) return;
body.innerHTML = '';
const maxCpu = Math.max(1, ...d.threads.map(t => t.cpu_pct));
for (const t of d.threads.slice(0, 40)) {
const pct = t.cpu_pct;
const barW = Math.max(1, (pct / maxCpu) * 100);
const barColor = pct > 50 ? '#ff4169' : pct > 15 ? '#ffaa00' : '#00d4ff';
// Build target/current cell
let targetText = '';
if (t.py_target) {
targetText = t.py_target;
if (t.py_module) targetText = `${t.py_module}.${targetText}`;
}
if (t.py_current) {
targetText += targetText ? ` | ${t.py_current}` : t.py_current;
}
const row = el('tr', { class: pct > 30 ? 'dbg-row-hot' : '' }, [
el('td', { class: 'dbg-num' }, [String(t.tid)]),
el('td', { class: 'dbg-mono' }, [t.name]),
el('td', { class: 'dbg-mono' }, [t.py_name || '--']),
el('td', { class: 'dbg-mono dbg-target', title: targetText }, [targetText || '--']),
el('td', {}, [t.state]),
el('td', { class: 'dbg-num' }, [pct.toFixed(1)]),
el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]),
]);
body.appendChild(row);
}
}
function updatePyThreadTable(d) {
const body = document.getElementById('pyThreadBody');
if (!body || !d.py_threads) return;
body.innerHTML = '';
for (const t of d.py_threads) {
// Format current frame as "file:line func()"
let currentFrame = '--';
if (t.stack_top && t.stack_top.length > 0) {
const f = t.stack_top[0];
currentFrame = `${f.file}:${f.line} ${f.func}()`;
}
// Build full stack tooltip
let stackTooltip = '';
if (t.stack_top) {
stackTooltip = t.stack_top.map(f => `${f.file}:${f.line} ${f.func}()`).join('\n');
}
const targetFile = t.target_file || t.target_module || '';
const shortFile = targetFile.split('/').slice(-2).join('/');
const row = el('tr', {}, [
el('td', { class: 'dbg-mono dbg-name' }, [t.name]),
el('td', { class: 'dbg-mono' }, [t.target_func || '--']),
el('td', { class: 'dbg-mono dbg-file', title: targetFile }, [shortFile || '--']),
el('td', { class: 'dbg-mono dbg-target', title: stackTooltip }, [currentFrame]),
el('td', {}, [t.daemon ? 'Yes' : 'No']),
el('td', {}, [t.alive ? 'Yes' : 'No']),
]);
body.appendChild(row);
}
}
function updateTracemallocByLine(d) {
const info = document.getElementById('tmInfo');
const body = document.getElementById('tmLineBody');
if (!body) return;
if (!d.tracemalloc_active) {
if (info) info.textContent = 'tracemalloc not active — click the button to start tracing';
body.innerHTML = '';
return;
}
if (info) info.textContent = `Traced: ${d.tracemalloc_current_kb.toFixed(0)} KB — Peak: ${d.tracemalloc_peak_kb.toFixed(0)} KB`;
body.innerHTML = '';
const items = d.tracemalloc_by_line || [];
if (!items.length) return;
const maxSize = Math.max(1, ...items.map(t => t.size_kb));
for (const t of items) {
const barW = Math.max(1, (t.size_kb / maxSize) * 100);
const sizeColor = t.size_kb > 100 ? '#ff4169' : t.size_kb > 30 ? '#ffaa00' : '#b44dff';
const row = el('tr', { class: t.size_kb > 100 ? 'dbg-row-hot' : '' }, [
el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]),
el('td', { class: 'dbg-num' }, [String(t.line)]),
el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]),
el('td', { class: 'dbg-num' }, [String(t.count)]),
el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${sizeColor}` })]),
]);
body.appendChild(row);
}
}
function updateTracemallocByFile(d) {
const body = document.getElementById('tmFileBody');
if (!body) return;
body.innerHTML = '';
const items = d.tracemalloc_by_file || [];
if (!items.length || !d.tracemalloc_active) return;
const maxSize = Math.max(1, ...items.map(t => t.size_kb));
for (const t of items) {
const barW = Math.max(1, (t.size_kb / maxSize) * 100);
const row = el('tr', {}, [
el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]),
el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]),
el('td', { class: 'dbg-num' }, [String(t.count)]),
el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:#b44dff` })]),
]);
body.appendChild(row);
}
}
function updateOpenFilesTable(d) {
const body = document.getElementById('fdBody');
if (!body) return;
body.innerHTML = '';
const items = d.open_files || [];
if (!items.length) return;
const maxCount = Math.max(1, ...items.map(f => f.count));
for (const f of items) {
const barW = Math.max(1, (f.count / maxCount) * 100);
const typeColors = {
file: '#00d4ff', socket: '#ff4169', pipe: '#ffaa00',
device: '#888', proc: '#666', temp: '#b44dff', anon: '#555', other: '#444'
};
const barColor = typeColors[f.type] || '#444';
const fdStr = f.fds.join(', ') + (f.count > f.fds.length ? '...' : '');
const row = el('tr', { class: f.count > 5 ? 'dbg-row-warn' : '' }, [
el('td', { class: 'dbg-mono dbg-target', title: f.target }, [f.target]),
el('td', {}, [el('span', { class: `dbg-type-badge dbg-type-${f.type}` }, [f.type])]),
el('td', { class: 'dbg-num' }, [String(f.count)]),
el('td', { class: 'dbg-mono dbg-fds' }, [fdStr]),
el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]),
]);
body.appendChild(row);
}
}
/* ============================================================
* Graph + tooltip
* ============================================================ */
function getGraphLayout() {
if (!graphCanvas) return null;
const W = graphCanvas.width;
const H = graphCanvas.height;
const dpr = window.devicePixelRatio || 1;
const pad = { l: 50 * dpr, r: 60 * dpr, t: 10 * dpr, b: 25 * dpr };
return { W, H, dpr, pad, gW: W - pad.l - pad.r, gH: H - pad.t - pad.b };
}
function onGraphMouseMove(e) {
if (!graphCanvas || history.ts.length < 2) return;
const rect = graphCanvas.getBoundingClientRect();
const L = getGraphLayout();
if (!L) return;
const mouseX = (e.clientX - rect.left) * L.dpr;
const frac = (mouseX - L.pad.l) / L.gW;
const idx = Math.round(frac * (history.ts.length - 1));
if (idx < 0 || idx >= history.ts.length) {
hoverIndex = -1;
if (tooltipEl) tooltipEl.style.display = 'none';
return;
}
hoverIndex = idx;
// Position & populate tooltip
if (tooltipEl) {
const ago = history.ts[history.ts.length - 1] - history.ts[idx];
const ts = new Date(history.ts[idx] * 1000);
const timeStr = ts.toLocaleTimeString();
tooltipEl.innerHTML = `
<div class="dbg-tt-time">${timeStr} (-${formatTimeAgo(ago)})</div>
<div class="dbg-tt-row"><span class="dbg-tt-dot" style="background:#00d4ff"></span>CPU: <b>${history.cpu[idx].toFixed(1)}%</b></div>
<div class="dbg-tt-row"><span class="dbg-tt-dot" style="background:#00ff6a"></span>RSS: <b>${(history.rss[idx] / 1024).toFixed(1)} MB</b></div>
<div class="dbg-tt-row"><span class="dbg-tt-dot" style="background:#ff4169"></span>FDs: <b>${history.fd[idx]}</b></div>
<div class="dbg-tt-row"><span class="dbg-tt-dot" style="background:#ffaa00"></span>Threads: <b>${history.threads[idx]}</b></div>
<div class="dbg-tt-row"><span class="dbg-tt-dot" style="background:#b44dff"></span>Swap: <b>${(history.swap[idx] / 1024).toFixed(1)} MB</b></div>
`;
tooltipEl.style.display = 'block';
// Tooltip positioning (CSS pixels)
const cssX = (L.pad.l / L.dpr) + (idx / (history.ts.length - 1)) * (L.gW / L.dpr);
const containerW = graphCanvas.parentElement.clientWidth;
const ttW = tooltipEl.offsetWidth;
let left = cssX + 12;
if (left + ttW > containerW - 10) left = cssX - ttW - 12;
tooltipEl.style.left = `${Math.max(0, left)}px`;
tooltipEl.style.top = '10px';
}
}
function onGraphMouseLeave() {
hoverIndex = -1;
if (tooltipEl) tooltipEl.style.display = 'none';
}
function resizeCanvas() {
if (!graphCanvas) return;
const wrap = graphCanvas.parentElement;
const dpr = window.devicePixelRatio || 1;
graphCanvas.width = wrap.clientWidth * dpr;
graphCanvas.height = 240 * dpr;
graphCanvas.style.width = wrap.clientWidth + 'px';
graphCanvas.style.height = '240px';
}
function drawLoop() {
drawGraph();
graphRAF = requestAnimationFrame(drawLoop);
}
function drawGraph() {
const L = getGraphLayout();
if (!L || !graphCtx) return;
const { W, H, dpr, pad, gW, gH } = L;
const ctx = graphCtx;
ctx.clearRect(0, 0, W, H);
const pts = history.ts.length;
if (pts < 2) return;
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad.t + (gH * i) / 4;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
}
// Series
const series = [
{ data: history.cpu, color: '#00d4ff', label: 'CPU %' },
{ data: history.rss.map(v => v / 1024), color: '#00ff6a', label: 'RSS MB' },
{ data: history.fd, color: '#ff4169', label: 'FDs' },
{ data: history.threads, color: '#ffaa00', label: 'Threads' },
{ data: history.swap.map(v => v / 1024), color: '#b44dff', label: 'Swap MB' },
];
for (const s of series) {
if (!s.data.length) continue;
const max = Math.max(1, ...s.data) * 1.15;
ctx.strokeStyle = s.color;
ctx.lineWidth = 1.5 * dpr;
ctx.globalAlpha = 0.85;
ctx.beginPath();
for (let i = 0; i < s.data.length; i++) {
const x = pad.l + (i / (s.data.length - 1)) * gW;
const y = pad.t + gH - (s.data[i] / max) * gH;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Right-edge label
const lastVal = s.data[s.data.length - 1];
const lastY = pad.t + gH - (lastVal / max) * gH;
ctx.fillStyle = s.color;
ctx.font = `${10 * dpr}px monospace`;
ctx.textAlign = 'left';
ctx.fillText(`${lastVal.toFixed(1)}`, W - pad.r + 4 * dpr, lastY + 3 * dpr);
}
// Time axis
const timeSpan = history.ts[pts - 1] - history.ts[0];
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = `${9 * dpr}px monospace`;
ctx.textAlign = 'center';
for (let i = 0; i <= 4; i++) {
const frac = i / 4;
const x = pad.l + frac * gW;
ctx.fillText(`-${formatTimeAgo(timeSpan - timeSpan * frac)}`, x, H - 5 * dpr);
}
// Hover crosshair
if (hoverIndex >= 0 && hoverIndex < pts) {
const hx = pad.l + (hoverIndex / (pts - 1)) * gW;
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(hx, pad.t); ctx.lineTo(hx, pad.t + gH); ctx.stroke();
ctx.setLineDash([]);
// Dots on each series at hoverIndex
for (const s of series) {
if (!s.data.length || hoverIndex >= s.data.length) continue;
const max = Math.max(1, ...s.data) * 1.15;
const val = s.data[hoverIndex];
const y = pad.t + gH - (val / max) * gH;
ctx.fillStyle = s.color;
ctx.beginPath();
ctx.arc(hx, y, 4 * dpr, 0, Math.PI * 2);
ctx.fill();
}
}
}
function formatTimeAgo(secs) {
if (secs < 60) return `${Math.round(secs)}s`;
return `${Math.floor(secs / 60)}m${Math.round(secs % 60)}s`;
}
/* ============================================================
* Scoped CSS
* ============================================================ */
const SCOPED_CSS = `
.dbg-page { padding: 12px; max-width: 1600px; margin: 0 auto; }
.dbg-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.dbg-title { margin: 0; font-size: 1.3em; color: var(--text, #e0e0e0); }
.dbg-controls { display: flex; gap: 6px; flex-wrap: wrap; }
.dbg-btn { font-size: 0.78em; padding: 4px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; background: rgba(255,255,255,0.04); color: var(--text, #ccc); cursor: pointer; transition: all .15s; }
.dbg-btn:hover { background: rgba(255,255,255,0.1); }
.dbg-btn.active { background: rgba(0,212,255,0.15); border-color: #00d4ff; color: #00d4ff; }
/* KPI cards */
.dbg-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 14px; }
.dbg-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 12px; text-align: center; transition: border-color .3s, background .3s; }
.dbg-card.warm { border-color: #ffaa00; background: rgba(255,170,0,0.06); }
.dbg-card.hot { border-color: #ff4169; background: rgba(255,65,105,0.08); }
.dbg-card-value { font-size: 1.6em; font-weight: 700; font-family: monospace; color: var(--text, #fff); line-height: 1.2; }
.dbg-card-label { font-size: 0.72em; color: rgba(255,255,255,0.45); margin-top: 2px; text-transform: uppercase; letter-spacing: .5px; }
.dbg-card.hot .dbg-card-value { color: #ff4169; }
.dbg-card.warm .dbg-card-value { color: #ffaa00; }
/* Graph */
.dbg-graph-wrap { background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 8px; margin-bottom: 14px; }
.dbg-canvas-container { position: relative; }
.dbg-canvas { width: 100%; height: 240px; display: block; cursor: crosshair; }
.dbg-legend { display: flex; gap: 14px; padding: 0 4px 6px; flex-wrap: wrap; }
.dbg-legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 0.72em; color: rgba(255,255,255,0.55); }
.dbg-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
/* Tooltip */
.dbg-tooltip { display: none; position: absolute; top: 10px; left: 0; background: rgba(10,10,20,0.92); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 8px 12px; font-size: 0.76em; color: #ddd; pointer-events: none; z-index: 10; white-space: nowrap; backdrop-filter: blur(8px); box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
.dbg-tt-time { color: rgba(255,255,255,0.5); margin-bottom: 4px; font-size: 0.9em; }
.dbg-tt-row { display: flex; align-items: center; gap: 6px; line-height: 1.6; }
.dbg-tt-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dbg-tt-row b { color: #fff; }
/* Tables */
.dbg-section-title { font-size: 0.95em; color: var(--text, #ccc); margin: 16px 0 6px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 4px; }
.dbg-table-wrap { overflow-x: auto; margin-bottom: 10px; max-height: 350px; overflow-y: auto; }
.dbg-table { width: 100%; border-collapse: collapse; font-size: 0.76em; }
.dbg-table th { position: sticky; top: 0; background: rgba(20,20,30,0.95); text-align: left; padding: 5px 8px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; font-size: 0.82em; letter-spacing: .3px; border-bottom: 1px solid rgba(255,255,255,0.1); z-index: 1; }
.dbg-table td { padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text, #bbb); }
.dbg-table tr:hover td { background: rgba(255,255,255,0.04); }
.dbg-row-hot td { color: #ff4169 !important; }
.dbg-row-warn td { color: #ffaa00 !important; }
.dbg-mono { font-family: monospace; font-size: 0.9em; }
.dbg-num { text-align: right; font-family: monospace; }
.dbg-name { font-weight: 600; }
.dbg-file { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dbg-target { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dbg-fds { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; color: rgba(255,255,255,0.4); }
.dbg-bar { height: 10px; border-radius: 3px; min-width: 2px; transition: width .3s; }
.dbg-tm-info { font-size: 0.78em; color: rgba(255,255,255,0.4); margin-bottom: 6px; font-style: italic; }
/* Type badges */
.dbg-type-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 0.82em; font-weight: 600; }
.dbg-type-file { background: rgba(0,212,255,0.12); color: #00d4ff; }
.dbg-type-socket { background: rgba(255,65,105,0.12); color: #ff4169; }
.dbg-type-pipe { background: rgba(255,170,0,0.12); color: #ffaa00; }
.dbg-type-device { background: rgba(136,136,136,0.15); color: #aaa; }
.dbg-type-proc { background: rgba(100,100,100,0.15); color: #888; }
.dbg-type-temp { background: rgba(180,77,255,0.12); color: #b44dff; }
.dbg-type-anon { background: rgba(80,80,80,0.15); color: #777; }
.dbg-type-other { background: rgba(60,60,60,0.15); color: #666; }
`;

185
web/js/pages/bjorn.js Normal file
View File

@@ -0,0 +1,185 @@
/**
* Bjorn page module — EPD (e-paper display) live view.
*
* Displays a live-updating screenshot of the Bjorn device's e-paper display.
* The image is refreshed at a configurable interval fetched from /get_web_delay.
* Supports mouse-wheel zoom and auto-fits to the container on window resize.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $ } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'bjorn';
const DEFAULT_DELAY = 5000;
const ZOOM_FACTOR = 1.1;
let tracker = null;
let refreshInterval = null;
let currentScale = 1;
let delay = DEFAULT_DELAY;
let imgEl = null;
let containerEl = null;
/* ============================
* Mount
* ============================ */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
currentScale = 1;
// Fetch the configured refresh delay
try {
const data = await api.get('/get_web_delay', { timeout: 5000, retries: 1 });
if (data && typeof data.web_delay === 'number' && data.web_delay > 0) {
delay = data.web_delay;
}
} catch (err) {
console.warn(`[${PAGE}] Failed to fetch web_delay, using default ${DEFAULT_DELAY}ms:`, err.message);
delay = DEFAULT_DELAY;
}
// Build layout
imgEl = el('img', {
src: `/web/screen.png?t=${Date.now()}`,
alt: t('nav.bjorn'),
class: 'bjorn-epd-img',
style: {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
objectFit: 'contain',
display: 'block',
},
draggable: 'false',
});
containerEl = el('div', {
class: 'bjorn-container', style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
overflow: 'hidden',
}
}, [imgEl]);
container.appendChild(containerEl);
// Click to toggle UI (restored from old version)
const onImageClick = () => {
const topbar = $('.topbar');
const bottombar = $('.bottombar');
const console = $('.console');
const appContainer = $('#app');
const toggle = (el) => {
if (!el) return;
el.style.display = (el.style.display === 'none') ? '' : 'none';
};
toggle(topbar);
toggle(bottombar);
toggle(console);
// Expand/restore app-container to use full space when bars hidden
if (appContainer) {
const barsHidden = topbar && topbar.style.display === 'none';
if (barsHidden) {
appContainer.style.position = 'fixed';
appContainer.style.inset = '0';
appContainer.style.zIndex = '50';
} else {
appContainer.style.position = '';
appContainer.style.inset = '';
appContainer.style.zIndex = '';
}
}
// 🔥 Force reflow + refit after layout change
requestAnimationFrame(() => {
fitToContainer();
});
};
tracker.trackEventListener(imgEl, 'click', onImageClick);
// Fit image to container on initial load
fitToContainer();
// Set up periodic image refresh
refreshInterval = tracker.trackInterval(() => refreshImage(), delay);
// Mouse wheel zoom
const onWheel = (e) => {
e.preventDefault();
if (e.deltaY < 0) {
currentScale *= ZOOM_FACTOR;
} else {
currentScale /= ZOOM_FACTOR;
}
applyZoom();
};
tracker.trackEventListener(containerEl, 'wheel', onWheel, { passive: false });
// Window resize: re-fit image to container
const onResize = () => fitToContainer();
tracker.trackEventListener(window, 'resize', onResize);
}
/* ============================
* Unmount — guaranteed cleanup
* ============================ */
export function unmount() {
if (tracker) { tracker.cleanupAll(); tracker = null; }
refreshInterval = null;
imgEl = null;
containerEl = null;
currentScale = 1;
}
/* ============================
* Image refresh (graceful swap)
* ============================ */
function refreshImage() {
if (!imgEl) return;
const loader = new Image();
const cacheBust = `/web/screen.png?t=${Date.now()}`;
loader.onload = () => {
// Only swap if the element is still mounted
if (imgEl) {
imgEl.src = cacheBust;
}
};
// On error: keep the old image, do nothing
loader.onerror = () => {
console.debug(`[${PAGE}] Image refresh failed, keeping current frame`);
};
loader.src = cacheBust;
}
/* ============================
* Zoom helpers
* ============================ */
function applyZoom() {
if (!imgEl || !containerEl) return;
const baseHeight = containerEl.clientHeight;
imgEl.style.height = `${baseHeight * currentScale}px`;
imgEl.style.width = 'auto';
imgEl.style.maxWidth = 'none';
imgEl.style.maxHeight = 'none';
}
function fitToContainer() {
if (!imgEl || !containerEl) return;
// Reset scale on resize so the image re-fits
currentScale = 1;
imgEl.style.height = `${containerEl.clientHeight}px`;
imgEl.style.width = 'auto';
imgEl.style.maxWidth = '100%';
imgEl.style.maxHeight = '100%';
}

444
web/js/pages/credentials.js Normal file
View File

@@ -0,0 +1,444 @@
/**
* Credentials page module.
* Displays credentials organized by service with tabs, search, and CSV export.
* Endpoint: GET /list_credentials (returns HTML tables)
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'credentials';
const REFRESH_INTERVAL = 30000;
/* ── state ── */
let tracker = null;
let poller = null;
let serviceData = []; // [{ service, category, credentials: { headers, rows } }]
let currentCategory = 'all';
let searchGlobal = '';
let searchTerms = {};
let collapsedCards = new Set();
/* ── localStorage ── */
const LS_CARD = 'cred:card:collapsed:';
const getCardPref = (svc) => { try { return localStorage.getItem(LS_CARD + svc); } catch { return null; } };
const setCardPref = (svc, collapsed) => { try { localStorage.setItem(LS_CARD + svc, collapsed ? '1' : '0'); } catch { } };
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
container.appendChild(buildShell());
await fetchCredentials();
poller = new Poller(fetchCredentials, REFRESH_INTERVAL);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
serviceData = [];
currentCategory = 'all';
searchGlobal = '';
searchTerms = {};
collapsedCards.clear();
}
/* ── shell ── */
function buildShell() {
return el('div', { class: 'credentials-container' }, [
/* stats bar */
el('div', { class: 'stats-bar' }, [
statItem('🧩', 'stat-services', t('creds.services')),
statItem('🔐', 'stat-creds', t('creds.totalCredentials')),
statItem('🖥️', 'stat-hosts', t('creds.uniqueHosts')),
]),
/* global search */
el('div', { class: 'global-search-container' }, [
el('input', {
type: 'text', id: 'cred-global-search', class: 'global-search-input',
placeholder: t('common.search'), oninput: onGlobalSearch
}),
el('button', { class: 'clear-global-button', id: 'cred-clear-global', onclick: clearGlobalSearch }, ['✖']),
]),
/* tabs */
el('div', { class: 'tabs-container', id: 'cred-tabs' }),
/* services grid */
el('div', { class: 'services-grid', id: 'credentials-grid' }),
/* toast */
el('div', { class: 'copied-feedback', id: 'cred-toast' }, ['Copied to clipboard!']),
]);
}
function statItem(icon, id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-icon' }, [icon]),
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
/* ── fetch ── */
async function fetchCredentials() {
try {
const text = await fetch('/list_credentials').then(r => r.text());
const doc = new DOMParser().parseFromString(text, 'text/html');
const tables = doc.querySelectorAll('table');
serviceData = [];
tables.forEach(table => {
const titleEl = table.previousElementSibling;
if (titleEl && titleEl.textContent) {
const raw = titleEl.textContent.toLowerCase().replace('.csv', '').trim();
const credentials = parseTable(table);
serviceData.push({ service: raw, category: raw, credentials });
}
});
// Sort by most credentials first
serviceData.sort((a, b) => (b.credentials.rows?.length || 0) - (a.credentials.rows?.length || 0));
updateStats();
renderTabs();
renderServices();
applyPersistedCollapse();
} catch (err) {
console.error(`[${PAGE}] fetch error:`, err);
}
}
function parseTable(table) {
const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim());
const rows = Array.from(table.querySelectorAll('tr')).slice(1).map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return Object.fromEntries(headers.map((h, i) => [h, (cells[i]?.textContent || '').trim()]));
});
return { headers, rows };
}
/* ── stats ── */
function updateStats() {
const setVal = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; };
setVal('stat-services', serviceData.length);
setVal('stat-creds', serviceData.reduce((a, s) => a + (s.credentials.rows?.length || 0), 0));
// Count unique MACs
const macSet = new Set();
serviceData.forEach(s => {
(s.credentials.rows || []).forEach(r => {
for (const [k, v] of Object.entries(r)) {
if (k.toLowerCase().includes('mac')) {
const norm = normalizeMac(v);
if (norm) macSet.add(norm);
}
}
});
});
setVal('stat-hosts', macSet.size);
}
function normalizeMac(v) {
if (!v) return null;
const raw = String(v).toLowerCase().replace(/[^0-9a-f]/g, '');
if (raw.length !== 12) return null;
return raw.match(/.{2}/g).join(':');
}
/* ── tabs ── */
function getCategories() {
return [...new Set(serviceData.map(s => s.category))];
}
function computeBadgeCounts() {
const map = { all: 0 };
getCategories().forEach(cat => map[cat] = 0);
const needle = searchGlobal.toLowerCase();
serviceData.forEach(svc => {
const rows = svc.credentials.rows || [];
let count;
if (!needle) {
count = rows.length;
} else {
count = rows.reduce((acc, row) => {
const text = Object.values(row).join(' ').toLowerCase();
return acc + (text.includes(needle) ? 1 : 0);
}, 0);
}
map.all += count;
map[svc.category] = (map[svc.category] || 0) + count;
});
return map;
}
function renderTabs() {
const tabs = $('#cred-tabs');
if (!tabs) return;
const counts = computeBadgeCounts();
const cats = ['all', ...getCategories()];
empty(tabs);
cats.forEach(cat => {
const label = cat === 'all' ? 'All' : cat.toUpperCase();
const count = counts[cat] || 0;
const active = cat === currentCategory ? 'active' : '';
const tab = el('div', { class: `tab ${active}`, 'data-cat': cat }, [
label,
el('span', { class: 'tab-badge' }, [String(count)]),
]);
tab.onclick = () => {
currentCategory = cat;
tabs.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderServices();
applyPersistedCollapse();
};
tabs.appendChild(tab);
});
}
function updateBadges() {
const counts = computeBadgeCounts();
$$('#cred-tabs .tab').forEach(tab => {
const cat = tab.dataset.cat;
const badge = tab.querySelector('.tab-badge');
if (badge) badge.textContent = counts[cat] || 0;
});
}
/* ── services rendering ── */
function renderServices() {
const grid = $('#credentials-grid');
if (!grid) return;
empty(grid);
const needle = searchGlobal.toLowerCase();
// Filter by global search
let searched = serviceData.filter(svc => {
if (!needle) return true;
const titleMatch = svc.service.includes(needle);
const rowMatch = svc.credentials.rows.some(r =>
Object.values(r).join(' ').toLowerCase().includes(needle));
return titleMatch || rowMatch;
});
// Filter by category
if (currentCategory !== 'all') {
searched = searched.filter(s => s.category === currentCategory);
}
if (searched.length === 0) {
grid.appendChild(el('div', { style: 'text-align:center;color:var(--muted);padding:40px' }, [
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['🔍']),
'No credentials',
]));
updateBadges();
return;
}
searched.forEach(s => grid.appendChild(createServiceCard(s)));
// If global search active, auto-expand and filter rows
if (needle) {
$$('.service-card', grid).forEach(card => {
card.classList.remove('collapsed');
card.querySelectorAll('.credential-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(needle) ? '' : 'none';
});
});
}
updateBadges();
}
function createServiceCard(svc) {
const count = svc.credentials.rows.length;
const isCollapsed = collapsedCards.has(svc.service);
const card = el('div', {
class: `service-card ${isCollapsed ? 'collapsed' : ''}`,
'data-service': svc.service,
'data-credentials': String(count),
}, [
/* header */
el('div', { class: 'service-header', onclick: (e) => toggleCollapse(e, svc.service) }, [
el('span', { class: 'service-title' }, [svc.service.toUpperCase()]),
el('span', { class: 'service-count' }, [`Credentials: ${count}`]),
el('div', { class: 'search-container', onclick: e => e.stopPropagation() }, [
el('input', {
type: 'text', class: 'search-input', placeholder: 'Search...',
'data-service': svc.service, oninput: (e) => filterServiceCreds(e, svc.service)
}),
el('button', { class: 'clear-button', onclick: (e) => clearServiceSearch(e, svc.service) }, ['✖']),
]),
el('button', {
class: 'download-button', title: 'Download CSV',
onclick: (e) => downloadCSV(e, svc.service, svc.credentials)
}, ['💾']),
el('span', { class: 'collapse-indicator' }, ['▼']),
]),
/* content */
el('div', { class: 'service-content' }, [
...svc.credentials.rows.map(row => createCredentialItem(row)),
]),
]);
return card;
}
function createCredentialItem(row) {
return el('div', { class: 'credential-item' }, [
...Object.entries(row).map(([key, value]) => {
const val = String(value ?? '');
const bubbleClass = getBubbleClass(key);
return el('div', { class: 'credential-field' }, [
el('span', { class: 'field-label' }, [key]),
el('div', {
class: `field-value ${val.trim() ? bubbleClass : ''}`,
'data-value': val,
onclick: (e) => copyToClipboard(e.currentTarget),
title: 'Click to copy',
}, [val]),
]);
}),
]);
}
function getBubbleClass(key) {
const k = key.toLowerCase();
if (k === 'port') return 'bubble-orange';
if (['ip address', 'ip', 'hostname', 'mac address', 'mac'].includes(k)) return 'bubble-blue';
return 'bubble-green';
}
/* ── collapse ── */
function toggleCollapse(e, service) {
if (e.target.closest('.search-container') || e.target.closest('.download-button')) return;
const card = $(`.service-card[data-service="${service}"]`);
if (!card) return;
const nowCollapsed = !card.classList.contains('collapsed');
card.classList.toggle('collapsed');
if (nowCollapsed) collapsedCards.add(service);
else collapsedCards.delete(service);
setCardPref(service, nowCollapsed);
}
function applyPersistedCollapse() {
$$('.service-card').forEach(card => {
const svc = card.dataset.service;
const pref = getCardPref(svc);
if (pref === '1') {
card.classList.add('collapsed');
collapsedCards.add(svc);
} else if (pref === '0') {
card.classList.remove('collapsed');
collapsedCards.delete(svc);
} else {
// Default: collapsed
card.classList.add('collapsed');
}
});
}
/* ── search ── */
function onGlobalSearch(e) {
searchGlobal = e.target.value;
const clearBtn = $('#cred-clear-global');
if (clearBtn) clearBtn.classList.toggle('show', searchGlobal.length > 0);
renderServices();
applyPersistedCollapse();
}
function clearGlobalSearch() {
const inp = $('#cred-global-search');
if (inp) inp.value = '';
searchGlobal = '';
const clearBtn = $('#cred-clear-global');
if (clearBtn) clearBtn.classList.remove('show');
renderServices();
applyPersistedCollapse();
$$('.service-card').forEach(c => c.classList.add('collapsed'));
}
function filterServiceCreds(e, service) {
const filter = e.target.value.toLowerCase();
searchTerms[service] = filter;
const card = $(`.service-card[data-service="${service}"]`);
if (!card) return;
if (filter.length > 0) card.classList.remove('collapsed');
card.querySelectorAll('.credential-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filter) ? '' : 'none';
});
// Toggle clear button
const clearBtn = e.target.nextElementSibling;
if (clearBtn) clearBtn.classList.toggle('show', filter.length > 0);
}
function clearServiceSearch(e, service) {
e.stopPropagation();
const card = $(`.service-card[data-service="${service}"]`);
if (!card) return;
const inp = card.querySelector('.search-input');
if (inp) inp.value = '';
searchTerms[service] = '';
card.querySelectorAll('.credential-item').forEach(item => item.style.display = '');
const clearBtn = card.querySelector('.clear-button');
if (clearBtn) clearBtn.classList.remove('show');
}
/* ── copy ── */
function copyToClipboard(el) {
const text = el.dataset.value || '';
navigator.clipboard.writeText(text).then(() => {
showToast();
const bg = el.style.background;
el.style.background = '#4CAF50';
setTimeout(() => el.style.background = bg, 500);
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast();
});
}
function showToast() {
const toast = $('#cred-toast');
if (!toast) return;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
}
/* ── CSV export ── */
function downloadCSV(e, service, credentials) {
e.stopPropagation();
if (!credentials.rows || credentials.rows.length === 0) return;
const headers = Object.keys(credentials.rows[0]);
let csv = headers.join(',') + '\n';
credentials.rows.forEach(row => {
const values = headers.map(h => {
const v = String(row[h] ?? '');
return v.includes(',') ? `"${v.replace(/"/g, '""')}"` : v;
});
csv += values.join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${service}_credentials.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

724
web/js/pages/dashboard.js Normal file
View File

@@ -0,0 +1,724 @@
/**
* Dashboard page module — matches web_old/index.html layout & behavior.
* Visibility-aware polling, resource cleanup, safe DOM (no innerHTML).
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, setText, escapeHtml, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
let tracker = null;
let heavyPoller = null;
let lightPoller = null;
let uptimeTimer = null;
let uptimeSecs = 0;
/* ======================== Mount / Unmount ======================== */
export async function mount(container) {
tracker = new ResourceTracker('dashboard');
container.innerHTML = '';
container.appendChild(buildLayout());
const liveCard = document.getElementById('liveops-card');
if (liveCard) tracker.trackEventListener(liveCard, 'click', () => fetchAndPaintHeavy());
await fetchAndPaintHeavy();
heavyPoller = new Poller(fetchAndPaintHeavy, 60000, { immediate: false });
lightPoller = new Poller(fetchAndPaintLight, 5000, { immediate: false });
heavyPoller.start();
lightPoller.start();
}
export function unmount() {
if (heavyPoller) { heavyPoller.stop(); heavyPoller = null; }
if (lightPoller) { lightPoller.stop(); lightPoller = null; }
stopUptime();
if (tracker) { tracker.cleanupAll(); tracker = null; }
}
/* ======================== Layout (matches old index.html) ======================== */
function buildLayout() {
return el('div', { class: 'dashboard-container' }, [
// Live Ops header (tap to refresh)
el('section', { class: 'grid-stack', style: 'margin-bottom:12px' }, [
el('div', { class: 'card', id: 'liveops-card', style: 'cursor:pointer' }, [
el('div', { class: 'head' }, [
el('div', {}, [el('h2', { class: 'title' }, [t('dash.liveOps')])]),
el('span', { class: 'pill' }, [t('dash.lastUpdate') + ': ', el('span', { id: 'db-last-update' }, ['\u2014'])]),
]),
]),
]),
// Hero: Battery | Connectivity | Internet
el('section', { class: 'hero-grid' }, [
buildBatteryCard(),
buildConnCard(),
buildNetCard(),
]),
// KPI tiles
buildKpiGrid(),
]);
}
/* ======================== Battery Card ======================== */
function buildBatteryCard() {
return el('article', { class: 'battery-card naked' }, [
el('div', { class: 'battery-wrap' }, [
createBatterySVG(),
el('div', { class: 'batt-center', 'aria-live': 'polite' }, [
el('div', { class: 'bjorn-portrait', title: 'Bjorn' }, [
el('img', { id: 'bjorn-icon', src: '/web/images/bjornwebicon.png', alt: 'Bjorn' }),
el('span', { class: 'bjorn-lvl', id: 'bjorn-level' }, ['LVL 1']),
]),
el('div', { class: 'batt-val' }, [el('span', { id: 'sys-battery' }, ['\u2014']), '%']),
el('div', { class: 'batt-state', id: 'sys-battery-state' }, [
el('span', { id: 'sys-battery-state-text' }, ['\u2014']),
el('span', { class: 'batt-indicator' }, [
svgIcon('ico-usb', '0 0 24 24', [
{ tag: 'path', d: 'M12 2v14' },
{ tag: 'circle', cx: '12', cy: '20', r: '2' },
{ tag: 'path', d: 'M7 7h5l-2-2 2-2h-5zM12 10h5l-2-2 2-2h-5z' },
], true),
svgIcon('ico-batt', '0 0 24 24', [
{ tag: 'rect', x: '2', y: '7', width: '18', height: '10', rx: '2' },
{ tag: 'rect', x: '20', y: '10', width: '2', height: '4', rx: '1' },
{ tag: 'path', d: 'M9 9l-2 4h4l-2 4' },
], true),
]),
]),
]),
]),
]);
}
function createBatterySVG() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class', 'battery-ring');
svg.setAttribute('viewBox', '0 0 220 220');
svg.setAttribute('width', '220');
svg.setAttribute('height', '220');
svg.setAttribute('aria-hidden', 'true');
const defs = document.createElementNS(ns, 'defs');
// Gradient
const grad = document.createElementNS(ns, 'linearGradient');
grad.id = 'batt-grad';
grad.setAttribute('x1', '0%'); grad.setAttribute('y1', '0%');
grad.setAttribute('x2', '100%'); grad.setAttribute('y2', '100%');
const s1 = document.createElementNS(ns, 'stop');
s1.setAttribute('offset', '0%'); s1.setAttribute('stop-color', 'var(--ring1, var(--acid))');
const s2 = document.createElementNS(ns, 'stop');
s2.setAttribute('offset', '100%'); s2.setAttribute('stop-color', 'var(--ring2, var(--acid-2))');
grad.appendChild(s1); grad.appendChild(s2);
// Glow filter
const filter = document.createElementNS(ns, 'filter');
filter.id = 'batt-glow';
filter.setAttribute('x', '-50%'); filter.setAttribute('y', '-50%');
filter.setAttribute('width', '200%'); filter.setAttribute('height', '200%');
const drop = document.createElementNS(ns, 'feDropShadow');
drop.setAttribute('dx', '0'); drop.setAttribute('dy', '0');
drop.setAttribute('stdDeviation', '6');
drop.setAttribute('flood-color', 'var(--ringGlow, var(--glow-mid))');
filter.appendChild(drop);
defs.appendChild(grad); defs.appendChild(filter);
svg.appendChild(defs);
// Background ring
const bg = document.createElementNS(ns, 'circle');
bg.setAttribute('cx', '110'); bg.setAttribute('cy', '110'); bg.setAttribute('r', '92');
bg.setAttribute('class', 'batt-bg');
// Foreground ring
const fg = document.createElementNS(ns, 'circle');
fg.id = 'batt-fg';
fg.setAttribute('cx', '110'); fg.setAttribute('cy', '110'); fg.setAttribute('r', '92');
fg.setAttribute('pathLength', '100'); fg.setAttribute('class', 'batt-fg');
// Scan ring (charging glow)
const scan = document.createElementNS(ns, 'circle');
scan.id = 'batt-scan';
scan.setAttribute('cx', '110'); scan.setAttribute('cy', '110'); scan.setAttribute('r', '92');
scan.setAttribute('class', 'batt-scan');
svg.appendChild(bg); svg.appendChild(fg); svg.appendChild(scan);
return svg;
}
/** Tiny SVG icon builder. hidden=true sets display:none. */
function svgIcon(id, viewBox, elems, hidden) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
if (id) svg.id = id;
svg.setAttribute('viewBox', viewBox);
if (hidden) svg.style.display = 'none';
elems.forEach(spec => {
const e = document.createElementNS(ns, spec.tag || 'path');
for (const [k, v] of Object.entries(spec)) { if (k !== 'tag') e.setAttribute(k, v); }
svg.appendChild(e);
});
return svg;
}
/* ======================== Connectivity Card ======================== */
function buildConnCard() {
function row(id, paths) {
return el('div', { class: 'row', id: `row-${id}` }, [
el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', paths)]),
el('div', { class: 'details', id: `${id}-details` }, ['\u2014']),
el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: `${id}-state` }, ['OFF'])]),
]);
}
return el('article', { class: 'card conn-card', id: 'conn-card' }, [
el('div', { class: 'head', style: 'margin-bottom:6px' }, [
el('span', { class: 'title', style: 'font-size:18px' }, [t('dash.connectivity')]),
]),
row('wifi', [
{ d: 'M2 8c5.5-4.5 14.5-4.5 20 0' }, { d: 'M5 11c3.5-3 10.5-3 14 0' },
{ d: 'M8 14c1.8-1.6 6.2-1.6 8 0' }, { tag: 'circle', cx: '12', cy: '18', r: '1.5' },
]),
el('div', { class: 'submeta', id: 'wifi-under' }, ['\u2014']),
row('eth', [
{ tag: 'rect', x: '4', y: '3', width: '16', height: '8', rx: '2' },
{ d: 'M8 11v5' }, { d: 'M12 11v5' }, { d: 'M16 11v5' },
{ tag: 'rect', x: '7', y: '16', width: '10', height: '5', rx: '1' },
]),
el('div', { class: 'submeta', id: 'eth-under' }, ['\u2014']),
// USB — inline detail spans with IDs
el('div', { class: 'row', id: 'row-usb' }, [
el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', [
{ d: 'M12 2v14' }, { tag: 'circle', cx: '12', cy: '20', r: '2' },
{ d: 'M7 7h5l-2-2 2-2h-5zM12 10h5l-2-2 2-2h-5z' },
])]),
el('div', { class: 'details', id: 'usb-details' }, [
el('span', { class: 'key' }, ['USB Gadget']), ': ',
el('span', { id: 'usb-gadget-state', class: 'dim' }, ['OFF']), ' \u2022 ',
el('span', { class: 'key' }, ['Lease']), ': ',
el('span', { id: 'usb-lease', class: 'dim' }, ['\u2014']), ' \u2022 ',
el('span', { class: 'key' }, [t('dash.mode')]), ': ',
el('span', { id: 'usb-mode', class: 'dim' }, ['\u2014']),
]),
el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: 'usb-state' }, ['OFF'])]),
]),
// BT — inline detail spans with IDs
el('div', { class: 'row', id: 'row-bt' }, [
el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', [{ d: 'M7 7l10 10-5 5V2l5 5L7 17' }])]),
el('div', { class: 'details', id: 'bt-details' }, [
el('span', { class: 'key' }, ['BT Gadget']), ': ',
el('span', { id: 'bt-gadget-state', class: 'dim' }, ['OFF']), ' \u2022 ',
el('span', { class: 'key' }, ['Lease']), ': ',
el('span', { id: 'bt-lease', class: 'dim' }, ['\u2014']), ' \u2022 ',
el('span', { class: 'key' }, ['Connected to']), ': ',
el('span', { id: 'bt-connected', class: 'dim' }, ['\u2014']),
]),
el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: 'bt-state' }, ['OFF'])]),
]),
]);
}
/* ======================== Internet Card (Globe SVG) ======================== */
function buildNetCard() {
const globe = svgIcon(null, '0 0 64 64', [
{ tag: 'circle', cx: '32', cy: '32', r: '28', class: 'globe-rim' },
{ d: 'M4 32h56M32 4c10 8 10 48 0 56M32 4c-10 8-10 48 0 56', class: 'globe-lines' },
]);
globe.setAttribute('width', '80'); globe.setAttribute('height', '80');
globe.setAttribute('aria-hidden', 'true');
return el('article', { class: 'card net-card' }, [
el('div', { class: 'head', style: 'margin-bottom:6px' }, [
el('span', { class: 'title', style: 'font-size:18px' }, [t('dash.internet')]),
]),
el('div', { style: 'display:flex;align-items:center;gap:12px' }, [
el('div', { class: 'globe' }, [globe]),
el('div', {}, [el('span', { class: 'net-badge', id: 'net-badge' }, ['NO'])]),
]),
]);
}
/* ======================== KPI Grid ======================== */
function buildKpiGrid() {
const bar = (id) => el('div', { class: 'bar' }, [el('i', { id: `${id}-bar` })]);
return el('section', { class: 'kpi-cards' }, [
el('div', { class: 'kpi', id: 'kpi-hosts' }, [
el('div', { class: 'label' }, [t('dash.hostsAlive')]),
el('div', { class: 'val' }, [el('span', { id: 'val-present' }, ['0']), ' / ', el('span', { id: 'val-known' }, ['0'])]),
]),
el('div', { class: 'kpi', id: 'kpi-ports-alive' }, [
el('div', { class: 'label' }, [t('netkb.openPorts')]),
el('div', { class: 'val', id: 'val-open-ports-alive' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-wardrive' }, [
el('div', { class: 'label' }, [t('dash.wifiKnown')]),
el('div', { class: 'val', id: 'val-wardrive-known' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-cpu-ram' }, [
el('div', { class: 'submeta' }, ['CPU: ', el('b', { id: 'cpu-pct' }, ['0%'])]),
bar('cpu'),
el('div', { class: 'submeta' }, ['RAM: ', el('b', { id: 'ram-used' }, ['0']), ' / ', el('b', { id: 'ram-total' }, ['0'])]),
bar('ram'),
]),
el('div', { class: 'kpi', id: 'kpi-storage' }, [
el('div', { class: 'label' }, [t('dash.disk')]),
el('div', { class: 'submeta' }, ['Used: ', el('b', { id: 'sto-used' }, ['0']), ' / ', el('b', { id: 'sto-total' }, ['0'])]),
bar('sto'),
]),
el('div', { class: 'kpi', id: 'kpi-gps' }, [
el('div', { class: 'label' }, ['GPS']),
el('div', { class: 'val', id: 'gps-state' }, ['OFF']),
el('div', { class: 'submeta', id: 'gps-info' }, ['\u2014']),
]),
el('div', { class: 'kpi', id: 'kpi-zombies' }, [
el('div', { class: 'label' }, [t('dash.zombies')]),
el('div', { class: 'val', id: 'val-zombies' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-creds' }, [
el('div', { class: 'label' }, [t('creds.title')]),
el('div', { class: 'val', id: 'val-creds' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-files' }, [
el('div', { class: 'label' }, [t('dash.dataFiles')]),
el('div', { class: 'val', id: 'val-files' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-vulns' }, [
el('div', { class: 'label' }, [t('vulns.title')]),
el('div', { class: 'val' }, [el('span', { id: 'val-vulns' }, ['0'])]),
el('div', {}, [el('span', { class: 'delta', id: 'vuln-delta' }, ['\u2014'])]),
]),
el('div', { class: 'kpi', id: 'kpi-scripts' }, [
el('div', { class: 'label' }, [t('dash.attackScripts')]),
el('div', { class: 'val', id: 'val-scripts' }, ['0']),
]),
el('div', { class: 'kpi', id: 'kpi-system' }, [
el('div', { class: 'label' }, [t('dash.system')]),
el('div', { class: 'submeta', id: 'sys-os' }, ['OS: \u2014']),
el('div', { class: 'submeta', id: 'sys-arch' }, ['Arch: \u2014']),
el('div', { class: 'submeta', id: 'sys-model' }, ['Model: \u2014']),
el('div', { class: 'submeta', id: 'sys-epd' }, ['Waveshare E-Ink: \u2014']),
]),
el('div', { class: 'kpi', id: 'kpi-mode' }, [
el('div', { class: 'label' }, [t('dash.mode')]),
el('div', { class: 'val', id: 'sys-mode' }, ['\u2014']),
]),
el('div', { class: 'kpi', id: 'kpi-uptime' }, [
el('div', { class: 'label' }, [t('dash.uptime')]),
el('div', { class: 'val', id: 'sys-uptime' }, ['\u2014']),
el('div', { class: 'submeta', id: 'bjorn-age' }, ['Bjorn age: \u2014']),
]),
el('div', { class: 'kpi', id: 'kpi-fds' }, [
el('div', { class: 'label' }, [t('dash.fileDescriptors')]),
el('div', { class: 'submeta' }, [el('b', { id: 'fds-used' }, ['0']), ' / ', el('b', { id: 'fds-max' }, ['0'])]),
bar('fds'),
]),
]);
}
/* ======================== Data normalization ======================== */
function normalizeStats(payload) {
if (!payload || typeof payload !== 'object') return null;
const s = payload.stats || {};
const sys = payload.system || {};
const battery = payload.battery || {};
const conn = payload.connectivity || {};
const gps = payload.gps || {};
return {
timestamp: payload.timestamp || Math.floor(Date.now() / 1000),
first_init_ts: payload.first_init_ts || payload.first_init_timestamp,
alive_hosts: s.alive_hosts_count ?? payload.alive_hosts,
known_hosts_total: s.all_known_hosts_count ?? payload.known_hosts_total,
open_ports_alive_total: s.total_open_ports ?? payload.open_ports_alive_total,
wardrive_known: s.wardrive_known ?? s.known_wifi ?? payload.wardrive_known ?? 0,
vulnerabilities: s.vulnerabilities_count ?? payload.vulnerabilities,
zombies: s.zombie_count ?? payload.zombies,
credentials: s.credentials_count ?? payload.credentials ?? payload.secrets,
attack_scripts: s.actions_count ?? payload.attack_scripts,
files_found: payload.files_found ?? 0,
vulns_missing_since_last_scan: payload.vulns_missing_since_last_scan ?? payload.vulns_delta ?? 0,
internet_access: !!payload.internet_access,
mode: payload.mode || 'AUTO',
uptime: payload.uptime,
bjorn_icon: payload.bjorn_icon,
bjorn_level: payload.bjorn_level,
system: {
os_name: sys.os_name || sys.os,
os_version: sys.os_version,
arch: sys.arch || sys.bits,
model: sys.model || sys.board,
waveshare_epd_connected: sys.waveshare_epd_connected,
waveshare_epd_type: sys.waveshare_epd_type,
cpu_pct: sys.cpu_pct,
ram_used_bytes: sys.ram_used_bytes,
ram_total_bytes: sys.ram_total_bytes,
storage_used_bytes: sys.storage_used_bytes,
storage_total_bytes: sys.storage_total_bytes,
open_fds: sys.open_fds ?? payload.system?.open_fds,
max_fds: sys.max_fds ?? sys.fds_limit ?? payload.system?.fds_limit,
},
battery: {
present: battery.present !== false,
level_pct: battery.level_pct,
state: battery.state,
charging: battery.charging === true,
source: battery.source,
},
gps: {
connected: !!gps.connected,
fix_quality: gps.fix_quality,
sats: gps.sats,
lat: gps.lat,
lon: gps.lon,
speed: gps.speed,
},
connectivity: {
wifi: !!(conn.wifi || conn.wifi_ssid || conn.wifi_ip),
wifi_radio_on: conn.wifi_radio_on === true,
wifi_ssid: conn.wifi_ssid || conn.ssid,
wifi_ip: conn.wifi_ip || conn.ip_wifi,
wifi_gw: conn.wifi_gw || conn.gw_wifi,
wifi_dns: conn.wifi_dns || conn.dns_wifi,
ethernet: !!(conn.ethernet || conn.eth_ip),
eth_link_up: conn.eth_link_up === true,
eth_ip: conn.eth_ip || conn.ip_eth,
eth_gw: conn.eth_gw || conn.gw_eth,
eth_dns: conn.eth_dns || conn.dns_eth,
usb_gadget: !!conn.usb_gadget,
usb_phys_on: conn.usb_phys_on === true,
usb_mode: conn.usb_mode || 'Device',
usb_lease_ip: conn.usb_lease_ip || conn.ip_neigh_lease_usb,
bt_gadget: !!conn.bt_gadget,
bt_radio_on: conn.bt_radio_on === true,
bt_lease_ip: conn.bt_lease_ip || conn.ip_neigh_lease_bt,
bt_connected_to: conn.bt_connected_to || conn.bluetooth_connected_to,
},
};
}
/* ======================== Fetchers ======================== */
async function fetchBjornStats() {
try {
const raw = await api.get('/api/bjorn/stats', { timeout: 8000, retries: 1 });
return normalizeStats(raw);
} catch { return null; }
}
async function fetchAndPaintHeavy() {
const data = await fetchBjornStats();
if (data) paintFull(data);
}
async function fetchAndPaintLight() {
const data = await fetchBjornStats();
if (!data) return;
if (data.system) paintCpuRam(data.system);
if (data.connectivity) paintConnectivity(data.connectivity);
}
/* ======================== Painters ======================== */
function setById(id, text) {
const e = document.getElementById(id);
if (e) e.textContent = String(text ?? '');
}
function setPctBar(id, pct) {
const e = document.getElementById(id);
if (!e) return;
pct = Math.max(0, Math.min(100, pct || 0));
e.style.width = pct.toFixed(1) + '%';
e.classList.remove('warm', 'hot');
if (pct >= 85) e.classList.add('hot');
else if (pct >= 60) e.classList.add('warm');
}
function fmtBytes(b) {
if (b == null) return '0';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0, x = Number(b);
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + ' ' + u[i];
}
function setRowState(rowId, state) {
const row = document.getElementById(rowId);
if (row) { row.classList.remove('on', 'off', 'err'); row.classList.add(state); }
}
function setRowPhys(rowId, on) {
const row = document.getElementById(rowId);
if (!row) return;
if (on) row.setAttribute('data-physon', '1');
else row.removeAttribute('data-physon');
}
function updateRingColors(percent) {
const fg = document.getElementById('batt-fg');
if (!fg) return;
let ring1, ring2, glow;
if (percent <= 20) {
ring1 = '#ff4d6d'; ring2 = '#ff6b6b'; glow = 'rgba(255,77,109,.9)';
} else if (percent <= 50) {
ring1 = '#ffd166'; ring2 = '#ffbe55'; glow = 'rgba(255,209,102,.85)';
} else {
const cs = getComputedStyle(document.documentElement);
ring1 = cs.getPropertyValue('--acid').trim() || '#00ff9a';
ring2 = cs.getPropertyValue('--acid-2').trim() || '#18f0ff';
glow = cs.getPropertyValue('--glow-mid').trim() || 'rgba(24,240,255,.7)';
}
fg.style.setProperty('--ring1', ring1);
fg.style.setProperty('--ring2', ring2);
fg.style.setProperty('--ringGlow', glow);
}
/* ---------- Full paint (60 s) ---------- */
function paintFull(data) {
// Battery
const batt = data.battery || {};
const hasBattery = batt.present !== false;
const percent = Math.max(0, Math.min(100, batt.level_pct ?? 0));
const stateRaw = String(batt.state || '').toLowerCase();
const charging = hasBattery && /charging|full/.test(stateRaw);
const plugged = !hasBattery;
const displayPct = plugged ? 100 : percent;
setById('sys-battery', hasBattery ? percent : '\u2014');
setById('sys-battery-state-text', plugged ? t('dash.plugged') : (charging ? t('dash.charging') : t('dash.discharging')));
const fg = document.getElementById('batt-fg');
if (fg) fg.style.strokeDashoffset = (100 - displayPct).toFixed(2);
const scan = document.getElementById('batt-scan');
if (scan) scan.style.opacity = charging ? 0.28 : 0.14;
updateRingColors(displayPct);
// Battery / USB icons
const icoUsb = document.getElementById('ico-usb');
const icoBatt = document.getElementById('ico-batt');
if (icoUsb && icoBatt) {
icoUsb.style.display = plugged ? '' : 'none';
icoBatt.style.display = !plugged ? '' : 'none';
icoUsb.classList.remove('pulse'); icoBatt.classList.remove('pulse');
if (plugged) icoUsb.classList.add('pulse'); else icoBatt.classList.add('pulse');
const stEl = document.getElementById('sys-battery-state');
if (stEl) stEl.style.color = plugged ? 'var(--acid-2)' : 'var(--ink)';
}
// Bjorn icon / level
if (data.bjorn_icon) {
const img = document.getElementById('bjorn-icon');
if (img) img.src = data.bjorn_icon;
}
if (data.bjorn_level != null) setById('bjorn-level', `LVL ${data.bjorn_level}`);
// Internet badge
const badge = document.getElementById('net-badge');
if (badge) {
badge.classList.remove('net-on', 'net-off');
badge.classList.add(data.internet_access ? 'net-on' : 'net-off');
badge.textContent = data.internet_access ? 'YES' : 'NO';
}
// KPIs
setById('val-present', data.alive_hosts ?? 0);
setById('val-known', data.known_hosts_total ?? 0);
setById('val-open-ports-alive', data.open_ports_alive_total ?? 0);
setById('val-wardrive-known', data.wardrive_known ?? 0);
setById('val-vulns', data.vulnerabilities ?? 0);
setById('val-creds', data.credentials ?? 0);
setById('val-zombies', data.zombies ?? 0);
setById('val-scripts', data.attack_scripts ?? 0);
setById('val-files', data.files_found ?? 0);
// Vuln delta
const dEl = document.getElementById('vuln-delta');
if (dEl) {
const delta = Number(data.vulns_missing_since_last_scan ?? 0);
dEl.classList.remove('good', 'bad');
if (delta > 0) dEl.classList.add('good');
if (delta < 0) dEl.classList.add('bad');
dEl.textContent = delta === 0 ? '= since last scan'
: (delta > 0 ? `\u2212${Math.abs(delta)} since last scan` : `+${Math.abs(delta)} since last scan`);
}
// System bars
const sys = data.system || {};
paintCpuRam(sys);
const stUsed = sys.storage_used_bytes ?? 0;
const stTot = sys.storage_total_bytes ?? 0;
setById('sto-used', fmtBytes(stUsed));
setById('sto-total', fmtBytes(stTot));
setPctBar('sto-bar', stTot ? (stUsed / stTot) * 100 : 0);
// System info
setById('sys-os', `OS: ${sys.os_name || '\u2014'}${sys.os_version ? ` ${sys.os_version}` : ''}`);
setById('sys-arch', `Arch: ${sys.arch || '\u2014'}`);
setById('sys-model', `Model: ${sys.model || '\u2014'}`);
const epd = sys.waveshare_epd_connected;
setById('sys-epd', `Waveshare E-Ink: ${epd === true ? 'ON' : epd === false ? 'OFF' : '\u2014'}${sys.waveshare_epd_type ? ` (${sys.waveshare_epd_type})` : ''}`);
// Mode + uptime
setById('sys-mode', (data.mode || '\u2014').toString().toUpperCase());
startUptime(data.uptime || '00:00:00');
// Age
setById('bjorn-age', data.first_init_ts ? `Bjorn age: ${humanAge(data.first_init_ts)}` : '');
// GPS
const gps = data.gps || {};
setById('gps-state', gps.connected ? 'ON' : 'OFF');
setById('gps-info', gps.connected
? (gps.fix_quality
? `Fix: ${gps.fix_quality} \u2022 Sats: ${gps.sats ?? '\u2014'} \u2022 ${gps.lat ?? '\u2014'}, ${gps.lon ?? '\u2014'} \u2022 ${gps.speed ?? '\u2014'}`
: 'Fix: \u2014')
: '\u2014');
// Connectivity
paintConnectivity(data.connectivity);
// Timestamp
const ts = data.timestamp ? new Date(data.timestamp * 1000) : new Date();
setById('db-last-update', ts.toLocaleString());
}
/* ---------- CPU / RAM (5 s) ---------- */
function paintCpuRam(sys) {
const cpu = Math.max(0, Math.min(100, sys.cpu_pct ?? 0));
setById('cpu-pct', `${Math.round(cpu)}%`);
setPctBar('cpu-bar', cpu);
const ramUsed = sys.ram_used_bytes ?? 0;
const ramTot = sys.ram_total_bytes ?? 0;
setById('ram-used', fmtBytes(ramUsed));
setById('ram-total', fmtBytes(ramTot));
setPctBar('ram-bar', ramTot ? (ramUsed / ramTot) * 100 : 0);
if (sys.open_fds !== undefined) {
setById('fds-used', sys.open_fds);
setById('fds-max', sys.max_fds ?? '');
setPctBar('fds-bar', sys.max_fds ? (sys.open_fds / sys.max_fds) * 100 : 0);
}
}
/* ---------- Connectivity ---------- */
function paintConnectivity(c) {
if (!c) return;
// WiFi
setRowState('row-wifi', c.wifi ? 'on' : 'off');
setRowPhys('row-wifi', c.wifi_radio_on === true);
setById('wifi-state', c.wifi ? 'ON' : 'OFF');
const wDet = document.getElementById('wifi-details');
if (wDet) {
wDet.textContent = '';
const parts = [];
if (c.wifi_ssid) parts.push(detailPair('SSID', c.wifi_ssid));
if (c.wifi_ip) parts.push(detailPair('IP', c.wifi_ip));
if (!parts.length) { wDet.textContent = '\u2014'; }
else parts.forEach((f, i) => { if (i) wDet.appendChild(document.createTextNode(' \u2022 ')); wDet.appendChild(f); });
}
setById('wifi-under', underline(c.wifi_gw, c.wifi_dns));
// Ethernet
setRowState('row-eth', c.ethernet ? 'on' : 'off');
setRowPhys('row-eth', c.eth_link_up === true);
setById('eth-state', c.ethernet ? 'ON' : 'OFF');
const eDet = document.getElementById('eth-details');
if (eDet) { eDet.textContent = ''; if (c.eth_ip) eDet.appendChild(detailPair('IP', c.eth_ip)); else eDet.textContent = '\u2014'; }
setById('eth-under', underline(c.eth_gw, c.eth_dns));
// USB
const usbG = !!c.usb_gadget;
setRowState('row-usb', (usbG || c.usb_lease_ip) ? 'on' : 'off');
setRowPhys('row-usb', c.usb_phys_on === true);
setById('usb-state', usbG ? 'ON' : 'OFF');
setById('usb-gadget-state', usbG ? 'ON' : 'OFF');
setById('usb-lease', c.usb_lease_ip || '\u2014');
setById('usb-mode', c.usb_mode || 'Device');
// BT
const btG = !!c.bt_gadget;
setRowState('row-bt', (btG || c.bt_lease_ip || c.bt_connected_to) ? 'on' : 'off');
setRowPhys('row-bt', c.bt_radio_on === true);
setById('bt-state', btG ? 'ON' : 'OFF');
setById('bt-gadget-state', btG ? 'ON' : 'OFF');
setById('bt-lease', c.bt_lease_ip || '\u2014');
setById('bt-connected', c.bt_connected_to || '\u2014');
}
/** Safe DOM: <span class="key">k</span>: <span>v</span> */
function detailPair(k, v) {
const f = document.createDocumentFragment();
const ks = document.createElement('span'); ks.className = 'key'; ks.textContent = k;
f.appendChild(ks); f.appendChild(document.createTextNode(': '));
const vs = document.createElement('span'); vs.textContent = v;
f.appendChild(vs);
return f;
}
function underline(gw, dns) {
const p = [];
if (gw) p.push(`GW: ${gw}`);
if (dns) p.push(`DNS: ${dns}`);
return p.length ? p.join(' \u2022 ') : '\u2014';
}
/* ======================== Uptime ticker ======================== */
function startUptime(str) {
stopUptime();
uptimeSecs = parseUptime(str);
tickUptime();
uptimeTimer = tracker?.trackInterval(() => { uptimeSecs += 1; tickUptime(); }, 1000);
}
function stopUptime() {
if (uptimeTimer && tracker) tracker.clearTrackedInterval(uptimeTimer);
uptimeTimer = null;
}
function tickUptime() { setById('sys-uptime', fmtUptime(uptimeSecs)); }
function parseUptime(str) {
if (!str) return 0;
let days = 0, h = 0, m = 0, s = 0;
const dMatch = str.match(/^(\d+)d\s+(.+)$/i);
if (dMatch) { days = parseInt(dMatch[1], 10) || 0; str = dMatch[2]; }
const parts = (str || '').split(':').map(x => parseInt(x, 10) || 0);
if (parts.length === 3) [h, m, s] = parts;
else if (parts.length === 2) [m, s] = parts;
return days * 86400 + h * 3600 + m * 60 + s;
}
function fmtUptime(total) {
total = Math.max(0, Math.floor(total || 0));
const d = Math.floor(total / 86400);
let r = total % 86400;
const h = Math.floor(r / 3600); r %= 3600;
const m = Math.floor(r / 60); const s = r % 60;
const hh = String(h).padStart(2, '0');
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
return d ? `${d}d ${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}`;
}
function humanAge(initTs) {
if (!initTs) return '\u2014';
const delta = Math.max(0, Date.now() / 1000 - Number(initTs));
const days = Math.floor(delta / 86400);
if (days < 60) return `${days} day${days !== 1 ? 's' : ''}`;
const months = Math.floor(days / 30.44);
if (months < 24) return `${months} month${months !== 1 ? 's' : ''}`;
const years = days / 365.25;
return `${years < 10 ? years.toFixed(1) : Math.round(years)} year${years >= 2 ? 's' : ''}`;
}

499
web/js/pages/database.js Normal file
View File

@@ -0,0 +1,499 @@
/**
* Database page module — Full SQLite browser.
* Sidebar tree with tables/views, main content area with table data,
* inline editing, search/sort/limit, CRUD, CSV/JSON export, danger zone ops.
* All endpoints under /api/db/*.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'database';
/* ── state ── */
let tracker = null;
let poller = null;
let catalog = []; // [{ name, type:'table'|'view', columns:[] }]
let activeTable = null; // name of the selected table/view
let tableData = null; // { columns:[], rows:[], total:0 }
let dirty = new Map(); // pk → { col: newVal, ... }
let selected = new Set();
let sortCol = null;
let sortDir = 'asc';
let searchText = '';
let rowLimit = 100;
let sidebarFilter = '';
let liveRefresh = false;
let disposeSidebarLayout = null;
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const shell = buildShell();
container.appendChild(shell);
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.db-sidebar',
mainSelector: '.db-main',
storageKey: 'sidebar:database',
mobileBreakpoint: 900,
toggleLabel: t('common.menu'),
mobileDefaultOpen: true,
});
await loadCatalog();
}
export function unmount() {
if (disposeSidebarLayout) { disposeSidebarLayout(); disposeSidebarLayout = null; }
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
catalog = []; activeTable = null; tableData = null;
dirty = new Map(); selected = new Set();
sortCol = null; sortDir = 'asc'; searchText = '';
rowLimit = 100; sidebarFilter = ''; liveRefresh = false;
}
/* ── shell ── */
function buildShell() {
const hideLabel = (() => {
const v = t('common.hide');
return v && v !== 'common.hide' ? v : 'Hide';
})();
return el('div', { class: 'db-container page-with-sidebar' }, [
/* sidebar */
el('aside', { class: 'db-sidebar page-sidebar', id: 'db-sidebar' }, [
el('div', { class: 'sidehead' }, [
el('div', { class: 'sidetitle' }, [t('nav.database')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [hideLabel]),
]),
el('div', { class: 'sidecontent' }, [
el('div', { class: 'tree-head' }, [
el('div', { class: 'pill' }, ['Tables']),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', type: 'button', onclick: loadCatalog }, [t('common.refresh')]),
]),
el('input', {
type: 'text', class: 'db-sidebar-filter', placeholder: t('db.filterTables'),
oninput: onSidebarFilter
}),
el('div', { class: 'db-tree', id: 'db-tree' }),
]),
]),
/* main */
el('div', { class: 'db-main page-main', id: 'db-main' }, [
el('div', { class: 'db-toolbar', id: 'db-toolbar', style: 'display:none' }, [
/* search + sort + limit */
el('input', {
type: 'text', class: 'db-search', placeholder: t('db.searchRows'),
oninput: onSearch
}),
el('select', { class: 'db-limit-select', onchange: onLimitChange }, [
...[50, 100, 250, 500, 1000].map(n =>
el('option', { value: String(n), ...(n === 100 ? { selected: '' } : {}) }, [String(n)])),
]),
el('label', { class: 'db-live-label' }, [
el('input', { type: 'checkbox', id: 'db-live', onchange: onLiveToggle }),
` ${t('db.autoRefresh')}`,
]),
]),
el('div', { class: 'db-actions', id: 'db-actions', style: 'display:none' }, [
el('button', { class: 'vuln-btn', id: 'db-btn-save', onclick: onSave }, [t('db.saveChanges')]),
el('button', { class: 'vuln-btn', id: 'db-btn-discard', onclick: onDiscard }, [t('db.discardChanges')]),
el('button', { class: 'vuln-btn', onclick: () => loadTable(activeTable) }, [t('common.refresh')]),
el('button', { class: 'vuln-btn', onclick: onAddRow }, ['+Row']),
el('button', { class: 'vuln-btn btn-danger', onclick: onDeleteSelected }, [t('db.deleteSelected')]),
el('button', { class: 'vuln-btn', onclick: () => exportTable('csv') }, ['CSV']),
el('button', { class: 'vuln-btn', onclick: () => exportTable('json') }, ['JSON']),
]),
/* table content */
el('div', { class: 'db-table-wrap', id: 'db-table-wrap' }, [
el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:60px 0' }, [
el('div', { style: 'font-size:3rem;margin-bottom:12px;opacity:.5' }, ['\u{1F5C4}\uFE0F']),
t('db.selectTableFromSidebar'),
]),
]),
/* danger zone */
el('div', { class: 'db-danger', id: 'db-danger', style: 'display:none' }, [
el('span', { style: 'font-weight:700;color:var(--critical)' }, [t('db.dangerZone')]),
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onVacuum }, ['VACUUM']),
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onTruncate }, ['Truncate']),
el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onDrop }, ['Drop']),
]),
/* status */
el('div', { class: 'db-status', id: 'db-status' }),
]),
]);
}
/* ── catalog ── */
async function loadCatalog() {
try {
const data = await api.get('/api/db/catalog', { timeout: 8000 });
if (Array.isArray(data)) {
catalog = data.map((item) => ({
name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''),
type: item?.type || 'table',
})).filter((item) => item.name);
} else {
const tables = Array.isArray(data?.tables) ? data.tables : [];
const views = Array.isArray(data?.views) ? data.views : [];
catalog = [
...tables.map((item) => ({
name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''),
type: item?.type || 'table',
})),
...views.map((item) => ({
name: typeof item === 'string' ? item : (item?.name || item?.view || item?.id || ''),
type: item?.type || 'view',
})),
].filter((item) => item.name);
}
renderTree();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
setStatus(t('db.failedLoadCatalog'));
}
}
function renderTree() {
const tree = $('#db-tree');
if (!tree) return;
empty(tree);
const needle = sidebarFilter.toLowerCase();
const tables = catalog.filter((t) => (t.type || 'table') === 'table');
const views = catalog.filter((t) => t.type === 'view');
const renderGroup = (label, items) => {
const filtered = needle ? items.filter(i => i.name.toLowerCase().includes(needle)) : items;
if (filtered.length === 0) return;
tree.appendChild(el('div', { class: 'db-tree-group' }, [
el('div', { class: 'db-tree-label' }, [`${label} (${filtered.length})`]),
...filtered.map(item =>
el('div', {
class: `tree-item ${item.name === activeTable ? 'active' : ''}`,
'data-name': item.name,
onclick: () => selectTable(item.name),
}, [
el('span', { class: 'db-tree-icon' }, [item.type === 'view' ? '\u{1F50D}' : '\u{1F4CB}']),
item.name,
])
),
]));
};
renderGroup('Tables', tables);
renderGroup('Views', views);
if (catalog.length === 0) {
tree.appendChild(el('div', { style: 'text-align:center;padding:20px;opacity:.5' }, [t('db.noTables')]));
}
}
function onSidebarFilter(e) {
sidebarFilter = e.target.value;
renderTree();
}
/* ── select table ── */
async function selectTable(name) {
activeTable = name;
sortCol = null; sortDir = 'asc';
searchText = ''; dirty.clear(); selected.clear();
renderTree();
showToolbar(true);
await loadTable(name);
}
function showToolbar(show) {
const toolbar = $('#db-toolbar');
const actions = $('#db-actions');
const danger = $('#db-danger');
if (toolbar) toolbar.style.display = show ? '' : 'none';
if (actions) actions.style.display = show ? '' : 'none';
if (danger) danger.style.display = show ? '' : 'none';
}
/* ── load table data ── */
async function loadTable(name) {
if (!name) return;
setStatus(t('common.loading'));
try {
const params = new URLSearchParams();
params.set('limit', String(rowLimit));
if (sortCol) { params.set('sort', sortCol); params.set('dir', sortDir); }
if (searchText) params.set('search', searchText);
const data = await api.get(`/api/db/table/${encodeURIComponent(name)}?${params}`, { timeout: 10000 });
tableData = data;
renderTable();
setStatus(`${data.rows?.length || 0} of ${data.total ?? '?'} rows`);
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
setStatus(t('db.failedLoadTable'));
const wrap = $('#db-table-wrap');
if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.errorLoadingData')])); }
}
}
/* ── render table ── */
function renderTable() {
const wrap = $('#db-table-wrap');
if (!wrap || !tableData) return;
empty(wrap);
const cols = tableData.columns || [];
const rows = tableData.rows || [];
if (cols.length === 0) {
wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.emptyTable')]));
return;
}
const thead = el('thead', {}, [
el('tr', {}, [
el('th', { class: 'db-th-sel' }, [
el('input', { type: 'checkbox', onchange: onSelectAll }),
]),
...cols.map((col) =>
el('th', {
class: sortCol === col ? 'sorted' : '',
onclick: () => toggleSort(col),
}, [col, sortCol === col ? (sortDir === 'asc' ? ' \u2191' : ' \u2193') : '']),
),
]),
]);
const tbody = el('tbody');
rows.forEach((row, idx) => {
const pk = rowPK(row, idx);
const isSelected = selected.has(pk);
const isDirty = dirty.has(pk);
const tr = el('tr', {
class: `db-tr ${isSelected ? 'selected' : ''} ${isDirty ? 'dirty' : ''}`,
'data-pk': pk,
}, [
el('td', { class: 'db-td db-td-sel' }, [
el('input', {
type: 'checkbox',
...(isSelected ? { checked: '' } : {}),
onchange: (e) => toggleRowSelection(pk, e.target.checked),
}),
]),
...cols.map((col) => {
const currentVal = dirty.get(pk)?.[col] ?? (row[col] ?? '').toString();
const originalVal = (row[col] ?? '').toString();
return el('td', { class: 'db-td', 'data-col': col }, [
el('span', {
class: 'db-cell',
contentEditable: 'true',
spellcheck: 'false',
'data-pk': pk,
'data-col': col,
'data-orig': originalVal,
onblur: onCellBlur,
}, [currentVal]),
]);
}),
]);
tbody.appendChild(tr);
});
wrap.appendChild(el('table', { class: 'db data-table' }, [thead, tbody]));
updateDirtyUI();
}
function rowPK(row, idx) {
/* Try 'id' or 'rowid' as PK; fallback to index */
if (row.id !== undefined) return String(row.id);
if (row.rowid !== undefined) return String(row.rowid);
return `_idx_${idx}`;
}
/* ── sorting ── */
function toggleSort(col) {
if (sortCol === col) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortCol = col;
sortDir = 'asc';
}
loadTable(activeTable);
}
/* ── search ── */
function onSearch(e) {
searchText = e.target.value;
loadTable(activeTable);
}
/* ── limit ── */
function onLimitChange(e) {
rowLimit = parseInt(e.target.value, 10) || 100;
loadTable(activeTable);
}
/* ── live refresh ── */
function onLiveToggle(e) {
liveRefresh = e.target.checked;
if (liveRefresh) {
poller = new Poller(() => loadTable(activeTable), 5000);
poller.start();
} else {
if (poller) { poller.stop(); poller = null; }
}
}
/* ── selection ── */
function onSelectAll(e) {
const rows = tableData?.rows || [];
if (e.target.checked) {
rows.forEach((r, i) => selected.add(rowPK(r, i)));
} else {
selected.clear();
}
renderTable();
}
function toggleRowSelection(pk, checked) {
if (checked) selected.add(pk); else selected.delete(pk);
const tr = document.querySelector(`tr.db-tr[data-pk="${pk}"]`);
if (tr) tr.classList.toggle('selected', checked);
}
/* ── inline editing ── */
function onCellBlur(e) {
const span = e.target;
const pk = span.dataset.pk;
const col = span.dataset.col;
const orig = span.dataset.orig;
const newVal = span.textContent;
if (newVal === orig) {
/* revert — remove from dirty if no other changes */
const changes = dirty.get(pk);
if (changes) {
delete changes[col];
if (Object.keys(changes).length === 0) dirty.delete(pk);
}
} else {
if (!dirty.has(pk)) dirty.set(pk, {});
dirty.get(pk)[col] = newVal;
}
updateDirtyUI();
}
function updateDirtyUI() {
const saveBtn = $('#db-btn-save');
const discardBtn = $('#db-btn-discard');
const hasDirty = dirty.size > 0;
if (saveBtn) saveBtn.classList.toggle('btn-primary', hasDirty);
if (discardBtn) discardBtn.style.opacity = hasDirty ? '1' : '0.4';
}
/* ── save ── */
async function onSave() {
if (dirty.size === 0) return;
setStatus(t('common.saving'));
try {
const updates = [];
dirty.forEach((changes, pk) => {
updates.push({ pk, changes });
});
await api.post('/api/db/update', { table: activeTable, updates });
dirty.clear();
toast(t('db.changesSaved'), 2000, 'success');
await loadTable(activeTable);
} catch (err) {
toast(`${t('db.saveFailed')}: ${err.message}`, 3000, 'error');
setStatus(t('db.saveFailed'));
}
}
function onDiscard() {
dirty.clear();
renderTable();
toast(t('db.changesDiscarded'), 1500);
}
/* ── add row ── */
async function onAddRow() {
setStatus(t('db.insertingRow'));
try {
await api.post('/api/db/insert', { table: activeTable });
toast(t('db.rowInserted'), 2000, 'success');
await loadTable(activeTable);
} catch (err) {
toast(`${t('db.insertFailed')}: ${err.message}`, 3000, 'error');
}
}
/* ── delete selected ── */
async function onDeleteSelected() {
if (selected.size === 0) { toast(t('db.noRowsSelected'), 1500); return; }
setStatus(t('db.deletingRowsCount', { count: selected.size }));
try {
await api.post('/api/db/delete', { table: activeTable, pks: [...selected] });
selected.clear();
toast(t('db.rowsDeleted'), 2000, 'success');
await loadTable(activeTable);
} catch (err) {
toast(`${t('common.deleteFailed')}: ${err.message}`, 3000, 'error');
}
}
/* ── export ── */
function exportTable(format) {
if (!activeTable) return;
window.location.href = `/api/db/export/${encodeURIComponent(activeTable)}?format=${format}`;
}
/* ── danger zone ── */
async function onVacuum() {
setStatus(t('db.runningVacuum'));
try {
await api.post('/api/db/vacuum', {});
toast(t('db.vacuumComplete'), 2000, 'success');
setStatus(t('db.vacuumDone'));
} catch (err) {
toast(`${t('db.vacuumFailed')}: ${err.message}`, 3000, 'error');
}
}
async function onTruncate() {
if (!activeTable) return;
if (!confirm(t('db.confirmTruncate', { table: activeTable }))) return;
setStatus(t('db.truncating'));
try {
await api.post(`/api/db/truncate/${encodeURIComponent(activeTable)}`, {});
toast(t('db.tableTruncated'), 2000, 'success');
await loadTable(activeTable);
} catch (err) {
toast(`${t('db.truncateFailed')}: ${err.message}`, 3000, 'error');
}
}
async function onDrop() {
if (!activeTable) return;
if (!confirm(t('db.confirmDrop', { table: activeTable }))) return;
setStatus(t('db.dropping'));
try {
await api.post(`/api/db/drop/${encodeURIComponent(activeTable)}`, {});
toast(t('db.droppedTable', { table: activeTable }), 2000, 'success');
activeTable = null;
showToolbar(false);
const wrap = $('#db-table-wrap');
if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.tableDropped')])); }
await loadCatalog();
} catch (err) {
toast(`${t('db.dropFailed')}: ${err.message}`, 3000, 'error');
}
}
/* ── status bar ── */
function setStatus(msg) {
const el2 = $('#db-status');
if (el2) el2.textContent = msg || '';
}

952
web/js/pages/files.js Normal file
View File

@@ -0,0 +1,952 @@
/**
* Files Explorer page module.
* Parity target: web_old/files_explorer.html behavior in SPA form.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { el, $, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'files';
let tracker = null;
let root = null;
let currentPath = [];
let allFiles = [];
let isGridView = true;
let isMultiSelectMode = false;
let searchValue = '';
let selectedTargetPath = null;
let absoluteBasePath = '/home/bjorn';
const selectedItems = new Map(); // relPath -> { name, is_directory, relPath, absPath, size }
let contextMenuEl = null;
let moveModalEl = null;
function L(key, fallback, vars = {}) {
const v = t(key, vars);
return v === key ? fallback : v;
}
function q(sel, base = root) {
return base ? base.querySelector(sel) : null;
}
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
wireStaticEvents();
updateViewModeButton();
await loadAllFiles();
}
export function unmount() {
removeContextMenu();
closeMoveDialog();
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
root = null;
currentPath = [];
allFiles = [];
isGridView = true;
isMultiSelectMode = false;
searchValue = '';
selectedTargetPath = null;
absoluteBasePath = '/home/bjorn';
selectedItems.clear();
}
function buildShell() {
return el('div', { class: 'files-container' }, [
el('div', { class: 'loot-container' }, [
el('div', { class: 'file-explorer' }, [
el('div', { class: 'toolbar-buttons' }, [
el('button', {
class: 'action-button',
id: 'viewModeBtn',
title: L('common.view', 'View'),
}, ['\u25A6']),
el('button', {
class: 'action-button',
id: 'multiSelectBtn',
}, [`\u229E ${L('common.selectAll', 'Select')}`]),
el('button', {
class: 'action-button',
id: 'newFolderBtn',
}, [`\u{1F4C1}+ ${L('common.new', 'New')} ${L('common.directory', 'folder')}`]),
el('button', {
class: 'action-button',
id: 'renameBtn',
style: 'display:none',
}, [`\u270E ${L('common.rename', 'Rename')}`]),
el('button', {
class: 'action-button',
id: 'moveBtn',
style: 'display:none',
}, [`\u2194 ${L('common.move', 'Move')}`]),
el('button', {
class: 'action-button delete',
id: 'deleteBtn',
style: 'display:none',
}, [`\u{1F5D1} ${L('common.delete', 'Delete')}`]),
el('button', {
class: 'action-button',
id: 'refreshBtn',
}, [`\u21BB ${L('common.refresh', 'Refresh')}`]),
]),
el('div', { class: 'search-container' }, [
el('input', {
type: 'text',
class: 'search-input',
id: 'search-input',
placeholder: L('files.searchPlaceholder', 'Search files...'),
}),
el('button', { class: 'clear-button', id: 'clear-button' }, ['\u2716']),
]),
el('div', { class: 'path-navigator' }, [
el('div', { class: 'nav-buttons' }, [
el('button', {
class: 'back-button',
id: 'backBtn',
title: L('common.back', 'Back'),
}, ['\u2190 ', L('common.back', 'Back')]),
]),
el('div', { class: 'current-path', id: 'currentPath' }),
]),
el('div', { class: 'files-grid', id: 'file-list' }),
]),
el('div', { class: 'upload-container' }, [
el('input', {
id: 'file-upload',
type: 'file',
multiple: '',
style: 'display:none',
}),
el('div', { id: 'drop-zone', class: 'drop-zone' }, [
L('files.dropzoneHint', 'Drag files or folders here or click to upload'),
]),
]),
el('div', { class: 'db-status', id: 'files-status' }),
]),
]);
}
function wireStaticEvents() {
const viewModeBtn = q('#viewModeBtn');
const multiSelectBtn = q('#multiSelectBtn');
const newFolderBtn = q('#newFolderBtn');
const renameBtn = q('#renameBtn');
const moveBtn = q('#moveBtn');
const deleteBtn = q('#deleteBtn');
const refreshBtn = q('#refreshBtn');
const searchInput = q('#search-input');
const clearBtn = q('#clear-button');
const backBtn = q('#backBtn');
const fileInput = q('#file-upload');
const dropZone = q('#drop-zone');
const list = q('#file-list');
if (viewModeBtn) tracker.trackEventListener(viewModeBtn, 'click', toggleView);
if (multiSelectBtn) tracker.trackEventListener(multiSelectBtn, 'click', toggleMultiSelect);
if (newFolderBtn) tracker.trackEventListener(newFolderBtn, 'click', createNewFolder);
if (renameBtn) tracker.trackEventListener(renameBtn, 'click', renameSelected);
if (moveBtn) tracker.trackEventListener(moveBtn, 'click', moveSelected);
if (deleteBtn) tracker.trackEventListener(deleteBtn, 'click', deleteSelectedItems);
if (refreshBtn) tracker.trackEventListener(refreshBtn, 'click', loadAllFiles);
if (searchInput) tracker.trackEventListener(searchInput, 'input', onSearchInput);
if (clearBtn) tracker.trackEventListener(clearBtn, 'click', clearSearch);
if (backBtn) tracker.trackEventListener(backBtn, 'click', navigateUp);
if (fileInput) tracker.trackEventListener(fileInput, 'change', handleFileUploadInput);
if (dropZone) {
tracker.trackEventListener(dropZone, 'click', () => fileInput?.click());
tracker.trackEventListener(dropZone, 'dragover', onDropZoneDragOver);
tracker.trackEventListener(dropZone, 'dragleave', onDropZoneDragLeave);
tracker.trackEventListener(dropZone, 'drop', onDropZoneDrop);
}
if (list) tracker.trackEventListener(list, 'contextmenu', showEmptySpaceContextMenu);
tracker.trackEventListener(document, 'click', () => removeContextMenu());
tracker.trackEventListener(window, 'keydown', onKeyDown);
tracker.trackEventListener(window, 'i18n:changed', () => {
updateStaticI18n();
renderCurrentFolder();
});
}
function onKeyDown(e) {
if (e.key === 'Escape') {
removeContextMenu();
closeMoveDialog();
}
}
function updateStaticI18n() {
const multiSelectBtn = q('#multiSelectBtn');
const newFolderBtn = q('#newFolderBtn');
const renameBtn = q('#renameBtn');
const moveBtn = q('#moveBtn');
const deleteBtn = q('#deleteBtn');
const refreshBtn = q('#refreshBtn');
const searchInput = q('#search-input');
const backBtn = q('#backBtn');
const dropZone = q('#drop-zone');
if (multiSelectBtn) multiSelectBtn.textContent = `\u229E ${isMultiSelectMode ? L('common.cancel', 'Cancel') : L('common.select', 'Select')}`;
if (newFolderBtn) newFolderBtn.textContent = `\u{1F4C1}+ ${L('common.new', 'New')} ${L('common.directory', 'folder')}`;
if (renameBtn) renameBtn.textContent = `\u270E ${L('common.rename', 'Rename')}`;
if (moveBtn) moveBtn.textContent = `\u2194 ${L('common.move', 'Move')}`;
if (deleteBtn) deleteBtn.textContent = `\u{1F5D1} ${L('common.delete', 'Delete')}`;
if (refreshBtn) refreshBtn.textContent = `\u21BB ${L('common.refresh', 'Refresh')}`;
if (searchInput) searchInput.placeholder = L('files.searchPlaceholder', 'Search files...');
if (backBtn) backBtn.textContent = `\u2190 ${L('common.back', 'Back')}`;
if (dropZone) dropZone.textContent = L('files.dropzoneHint', 'Drag files or folders here or click to upload');
updateViewModeButton();
}
async function loadAllFiles() {
setStatus(L('common.loading', 'Loading...'));
try {
const response = await fetch('/list_files');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
allFiles = Array.isArray(data) ? data : [];
absoluteBasePath = inferAbsoluteBasePath(allFiles) || '/home/bjorn';
renderCurrentFolder();
} catch (err) {
console.error(`[${PAGE}] loadAllFiles:`, err);
allFiles = [];
renderCurrentFolder();
setStatus(L('files.failedLoadDir', 'Failed to load directory'));
}
}
function inferAbsoluteBasePath(tree) {
let best = null;
function walk(items, segs) {
if (!Array.isArray(items)) return;
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const nextSegs = [...segs, item.name].filter(Boolean);
if (!item.is_directory && item.path && typeof item.path === 'string') {
const abs = item.path.replace(/\\/g, '/');
const rel = nextSegs.join('/');
if (rel && abs.endsWith('/' + rel)) {
best = abs.slice(0, abs.length - rel.length - 1);
} else {
best = abs.slice(0, abs.lastIndexOf('/'));
}
return;
}
if (item.is_directory && item.children) {
walk(item.children, nextSegs);
if (best) return;
}
}
}
walk(tree, []);
return best;
}
function renderCurrentFolder() {
const currentContent = findFolderContents(allFiles, currentPath);
const visibleItems = searchValue
? filterAllFiles(allFiles, searchValue)
: decorateFolderItems(currentContent, currentPath);
displayFiles(visibleItems);
updateCurrentPathDisplay();
updateButtonStates();
setStatus(L('files.itemsCount', '{{count}} item(s)', { count: visibleItems.length }));
}
function findFolderContents(data, path) {
if (!Array.isArray(data)) return [];
if (!path.length) return data;
let current = data;
for (const folder of path) {
const found = current.find((item) => item?.is_directory && item.name === folder);
if (!found || !Array.isArray(found.children)) return [];
current = found.children;
}
return current;
}
function decorateFolderItems(items, basePath) {
return (Array.isArray(items) ? items : []).map((item) => {
const relPath = [...basePath, item.name].filter(Boolean).join('/');
const absPath = item.path || buildAbsolutePath(relPath);
return {
...item,
_relPath: relPath,
_absPath: absPath,
_folderPath: item.is_directory ? relPath : basePath.join('/'),
_segments: item.is_directory ? [...basePath, item.name] : [...basePath],
};
});
}
function filterAllFiles(items, rawNeedle, segs = []) {
const needle = String(rawNeedle || '').toLowerCase().trim();
if (!needle) return [];
let out = [];
for (const item of (Array.isArray(items) ? items : [])) {
if (!item || typeof item !== 'object') continue;
const relPath = [...segs, item.name].filter(Boolean).join('/');
const absPath = item.path || buildAbsolutePath(relPath);
if ((item.name || '').toLowerCase().includes(needle)) {
out.push({
...item,
_relPath: relPath,
_absPath: absPath,
_folderPath: item.is_directory ? relPath : segs.join('/'),
_segments: item.is_directory ? [...segs, item.name] : [...segs],
});
}
if (item.is_directory && Array.isArray(item.children)) {
out = out.concat(filterAllFiles(item.children, needle, [...segs, item.name]));
}
}
return out;
}
function displayFiles(items) {
const container = q('#file-list');
if (!container) return;
empty(container);
container.className = isGridView ? 'files-grid' : 'files-list';
const sorted = [...items].sort((a, b) => {
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
return String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
});
if (!sorted.length) {
container.appendChild(el('div', { class: 'item-meta', style: 'padding:16px' }, [L('files.noFiles', 'No files found')]));
return;
}
for (const item of sorted) {
const relPath = item._relPath || '';
const absPath = item._absPath || buildAbsolutePath(relPath);
const nodeClass = `${isGridView ? 'grid-item' : 'list-item'} ${item.is_directory ? 'folder' : 'file'}`;
const node = el('div', { class: nodeClass });
node.dataset.path = relPath;
if (selectedItems.has(relPath)) node.classList.add('item-selected');
const icon = el('img', {
src: `/web/images/${item.is_directory ? 'mainfolder' : 'file'}.png`,
alt: item.is_directory ? L('common.directory', 'directory') : L('common.file', 'file'),
});
tracker.trackEventListener(icon, 'error', () => {
icon.src = '/web/images/attack.png';
});
const body = el('div', {}, [
el('div', { class: 'item-name' }, [item.name || L('common.unknown', 'unknown')]),
el('div', { class: 'item-meta' }, [
item.is_directory
? L('common.directory', 'directory')
: formatBytes(Number(item.size) || 0),
]),
]);
node.append(icon, body);
tracker.trackEventListener(node, 'click', (e) => {
e.preventDefault();
e.stopPropagation();
if (isMultiSelectMode) {
toggleItemSelection(node, {
name: item.name,
is_directory: !!item.is_directory,
relPath,
absPath,
size: item.size,
});
return;
}
if (item.is_directory) {
currentPath = Array.isArray(item._segments) ? [...item._segments] : relPath.split('/').filter(Boolean);
renderCurrentFolder();
} else {
window.location.href = `/download_file?path=${encodeURIComponent(relPath)}`;
}
});
tracker.trackEventListener(node, 'contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
showContextMenu(e, {
name: item.name,
is_directory: !!item.is_directory,
relPath,
absPath,
size: item.size,
});
});
container.appendChild(node);
}
}
function updateCurrentPathDisplay() {
const wrap = q('#currentPath');
if (!wrap) return;
empty(wrap);
const rootSeg = el('span', { class: 'path-segment' }, ['/']);
tracker.trackEventListener(rootSeg, 'click', () => {
currentPath = [];
renderCurrentFolder();
});
wrap.appendChild(rootSeg);
currentPath.forEach((folder, idx) => {
const seg = el('span', { class: 'path-segment' }, [folder]);
tracker.trackEventListener(seg, 'click', () => {
currentPath = currentPath.slice(0, idx + 1);
renderCurrentFolder();
});
wrap.appendChild(seg);
});
}
function navigateUp() {
if (!currentPath.length) return;
currentPath.pop();
renderCurrentFolder();
}
function toggleView() {
isGridView = !isGridView;
updateViewModeButton();
renderCurrentFolder();
}
function onSearchInput(e) {
searchValue = String(e.target?.value || '').toLowerCase().trim();
const clearBtn = q('#clear-button');
if (clearBtn) clearBtn.classList.toggle('show', !!searchValue);
renderCurrentFolder();
}
function clearSearch() {
const input = q('#search-input');
if (input) input.value = '';
searchValue = '';
const clearBtn = q('#clear-button');
if (clearBtn) clearBtn.classList.remove('show');
renderCurrentFolder();
}
function toggleMultiSelect() {
isMultiSelectMode = !isMultiSelectMode;
const explorer = q('.file-explorer');
const btn = q('#multiSelectBtn');
if (explorer) explorer.classList.toggle('multi-select-mode', isMultiSelectMode);
if (btn) btn.classList.toggle('active', isMultiSelectMode);
if (!isMultiSelectMode) clearSelection();
updateButtonStates();
updateStaticI18n();
}
function toggleItemSelection(node, item) {
if (!isMultiSelectMode) return;
const key = item.relPath;
if (selectedItems.has(key)) {
selectedItems.delete(key);
node.classList.remove('item-selected');
} else {
selectedItems.set(key, item);
node.classList.add('item-selected');
}
updateButtonStates();
}
function clearSelection() {
selectedItems.clear();
q('#file-list')?.querySelectorAll('.grid-item, .list-item').forEach((n) => n.classList.remove('item-selected'));
updateButtonStates();
}
function updateButtonStates() {
const n = selectedItems.size;
const renameBtn = q('#renameBtn');
const moveBtn = q('#moveBtn');
const deleteBtn = q('#deleteBtn');
const newFolderBtn = q('#newFolderBtn');
if (renameBtn) {
renameBtn.style.display = isMultiSelectMode && n === 1 ? 'inline-flex' : 'none';
renameBtn.disabled = !(isMultiSelectMode && n === 1);
}
if (moveBtn) {
moveBtn.style.display = isMultiSelectMode && n > 0 ? 'inline-flex' : 'none';
moveBtn.disabled = !(isMultiSelectMode && n > 0);
}
if (deleteBtn) {
deleteBtn.style.display = isMultiSelectMode ? 'inline-flex' : 'none';
deleteBtn.disabled = n === 0;
deleteBtn.textContent = `\u{1F5D1} ${L('common.delete', 'Delete')}${n > 0 ? ` (${n})` : ''}`;
}
if (newFolderBtn) {
newFolderBtn.style.display = isMultiSelectMode ? 'none' : 'inline-flex';
}
}
function showEmptySpaceContextMenu(event) {
if (event.target !== q('#file-list')) return;
event.preventDefault();
removeContextMenu();
const menu = createContextMenu(event.clientX, event.clientY);
const newFolder = el('div', {}, [`${L('common.new', 'New')} ${L('common.directory', 'Folder')}`]);
tracker.trackEventListener(newFolder, 'click', async () => {
removeContextMenu();
await createNewFolder();
});
menu.appendChild(newFolder);
openContextMenu(menu);
}
function showContextMenu(event, item) {
removeContextMenu();
const menu = createContextMenu(event.clientX, event.clientY);
const rename = el('div', {}, [L('common.rename', 'Rename')]);
const duplicate = el('div', {}, [L('common.duplicate', 'Duplicate')]);
const move = el('div', {}, [t('files.moveTo')]);
const del = el('div', {}, [L('common.delete', 'Delete')]);
tracker.trackEventListener(rename, 'click', async () => {
removeContextMenu();
await renameItem(item);
});
tracker.trackEventListener(duplicate, 'click', async () => {
removeContextMenu();
await duplicateItem(item);
});
tracker.trackEventListener(move, 'click', async () => {
removeContextMenu();
await showMoveToDialog([item]);
});
tracker.trackEventListener(del, 'click', async () => {
removeContextMenu();
await deleteItems([item], true);
});
menu.append(rename, duplicate, move, del);
openContextMenu(menu);
}
function createContextMenu(x, y) {
const menu = el('div', { class: 'context-menu' });
menu.style.position = 'fixed';
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
return menu;
}
function openContextMenu(menu) {
const host = root || document.body;
host.appendChild(menu);
contextMenuEl = menu;
}
function removeContextMenu() {
if (contextMenuEl && contextMenuEl.parentElement) {
contextMenuEl.parentElement.removeChild(contextMenuEl);
}
contextMenuEl = null;
}
async function renameSelected() {
if (selectedItems.size !== 1) return;
const item = Array.from(selectedItems.values())[0];
await renameItem(item);
}
async function moveSelected() {
if (!selectedItems.size) return;
await showMoveToDialog(Array.from(selectedItems.values()));
}
async function createNewFolder() {
const folderName = prompt(`${L('common.new', 'New')} ${L('common.directory', 'folder')}:`, 'New Folder');
if (!folderName) return;
const rel = buildRelativePath(folderName);
try {
const resp = await fetch('/create_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: rel }),
});
const data = await resp.json();
if (data.status !== 'success') throw new Error(data.message || 'Failed');
await loadAllFiles();
toast(L('common.success', 'Success'), 1600, 'success');
} catch (err) {
toast(`${L('common.error', 'Error')}: ${err.message}`, 2800, 'error');
}
}
async function renameItem(item) {
const newName = prompt(L('files.newNamePrompt', 'New name:'), item.name);
if (!newName || newName === item.name) return;
const parent = item.relPath.split('/').slice(0, -1).join('/');
const newPath = parent ? `${parent}/${newName}` : newName;
try {
const resp = await fetch('/rename_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_path: item.relPath, new_path: newPath }),
});
const data = await resp.json();
if (data.status !== 'success') throw new Error(data.message || 'Failed');
await loadAllFiles();
clearSelection();
toast(L('files.renamed', 'Renamed'), 1600, 'success');
} catch (err) {
toast(`${L('files.renameFailed', 'Rename failed')}: ${err.message}`, 3200, 'error');
}
}
async function duplicateItem(item) {
const dot = item.name.lastIndexOf('.');
const base = dot > 0 ? item.name.slice(0, dot) : item.name;
const ext = dot > 0 ? item.name.slice(dot) : '';
const newName = `${base} (copy)${ext}`;
const parent = item.relPath.split('/').slice(0, -1).join('/');
const targetPath = parent ? `${parent}/${newName}` : newName;
try {
const resp = await fetch('/duplicate_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: item.relPath, target_path: targetPath }),
});
const data = await resp.json();
if (data.status !== 'success') throw new Error(data.message || 'Failed');
await loadAllFiles();
toast(L('files.duplicated', 'Duplicated'), 1600, 'success');
} catch (err) {
toast(`${L('files.duplicateFailed', 'Duplicate failed')}: ${err.message}`, 3200, 'error');
}
}
async function deleteSelectedItems() {
if (!selectedItems.size) return;
await deleteItems(Array.from(selectedItems.values()), true);
}
async function deleteItems(items, askConfirm) {
if (!Array.isArray(items) || !items.length) return;
if (askConfirm) {
if (items.length === 1) {
const one = items[0];
const label = one.is_directory ? L('common.directory', 'directory') : L('common.file', 'file');
if (!confirm(L('files.confirmDelete', `Delete ${label} "${one.name}"?`, { label, name: one.name }))) return;
} else {
if (!confirm(L('files.confirmDeleteMany', 'Delete {{count}} item(s)?', { count: items.length }))) return;
}
}
const errors = [];
for (const item of items) {
const absPath = item.absPath || buildAbsolutePath(item.relPath);
let ok = false;
try {
const r1 = await fetch('/delete_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: absPath }),
});
const d1 = await r1.json();
ok = d1.status === 'success';
} catch {
ok = false;
}
if (!ok) {
try {
const r2 = await fetch('/delete_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: item.relPath }),
});
const d2 = await r2.json();
ok = d2.status === 'success';
} catch {
ok = false;
}
}
if (!ok) errors.push(item.name);
}
await loadAllFiles();
clearSelection();
if (isMultiSelectMode) toggleMultiSelect();
if (errors.length) toast(`${L('common.error', 'Error')}: ${errors.join(', ')}`, 3800, 'error');
else toast(L('common.deleted', 'Deleted'), 1600, 'success');
}
async function showMoveToDialog(items) {
closeMoveDialog();
selectedTargetPath = null;
moveModalEl = el('div', { class: 'modal' }, [
el('div', { class: 'modal-content' }, [
el('h2', {}, [L('files.moveToTitle', 'Move {{count}} item(s) to...', { count: items.length })]),
el('div', { id: 'folder-tree' }),
el('div', { class: 'modal-buttons' }, [
el('button', { id: 'cancelMoveBtn' }, [L('common.cancel', 'Cancel')]),
el('button', { class: 'primary', id: 'confirmMoveBtn' }, [L('common.move', 'Move')]),
]),
]),
]);
(root || document.body).appendChild(moveModalEl);
const cancelBtn = $('#cancelMoveBtn', moveModalEl);
const confirmBtn = $('#confirmMoveBtn', moveModalEl);
if (cancelBtn) tracker.trackEventListener(cancelBtn, 'click', closeMoveDialog);
if (confirmBtn) tracker.trackEventListener(confirmBtn, 'click', () => processMove(items));
tracker.trackEventListener(moveModalEl, 'click', (e) => {
if (e.target === moveModalEl) closeMoveDialog();
});
await loadFolderTree();
}
function closeMoveDialog() {
selectedTargetPath = null;
if (moveModalEl && moveModalEl.parentElement) {
moveModalEl.parentElement.removeChild(moveModalEl);
}
moveModalEl = null;
}
async function loadFolderTree() {
if (!moveModalEl) return;
const treeWrap = $('#folder-tree', moveModalEl);
if (!treeWrap) return;
empty(treeWrap);
try {
const resp = await fetch('/list_directories');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const dirs = await resp.json();
const rootItem = el('div', { class: 'folder-item', 'data-path': '' }, ['/', ' ', L('files.root', 'Root')]);
treeWrap.appendChild(rootItem);
bindFolderItem(rootItem);
renderDirectoryTree(treeWrap, dirs, 1);
} catch (err) {
treeWrap.appendChild(el('div', { class: 'item-meta' }, [`${L('common.error', 'Error')}: ${err.message}`]));
}
}
function renderDirectoryTree(container, dirs, level) {
for (const dir of (Array.isArray(dirs) ? dirs : [])) {
if (!dir.is_directory) continue;
const row = el('div', {
class: 'folder-item',
'data-path': dir.path || '',
style: `padding-left:${level * 16}px`,
}, ['\u{1F4C1} ', dir.name || 'folder']);
container.appendChild(row);
bindFolderItem(row);
if (Array.isArray(dir.children) && dir.children.length) {
renderDirectoryTree(container, dir.children, level + 1);
}
}
}
function bindFolderItem(node) {
tracker.trackEventListener(node, 'click', (e) => {
e.preventDefault();
e.stopPropagation();
q('#folder-tree')?.querySelectorAll('.folder-item.selected').forEach((n) => n.classList.remove('selected'));
node.classList.add('selected');
selectedTargetPath = node.getAttribute('data-path') || '';
});
}
async function processMove(items) {
if (selectedTargetPath == null) {
toast(L('files.selectDestinationFolder', 'Select a destination folder'), 2200, 'warning');
return;
}
const errors = [];
for (const item of items) {
const targetPath = selectedTargetPath ? `${selectedTargetPath}/${item.name}` : item.name;
try {
const resp = await fetch('/move_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: item.relPath, target_path: targetPath }),
});
const data = await resp.json();
if (data.status !== 'success') errors.push(item.name);
} catch {
errors.push(item.name);
}
}
closeMoveDialog();
await loadAllFiles();
clearSelection();
if (errors.length) toast(`${L('common.error', 'Error')}: ${errors.join(', ')}`, 3600, 'error');
else toast(L('files.moved', 'Moved'), 1600, 'success');
}
function updateViewModeButton() {
const viewModeBtn = q('#viewModeBtn');
if (!viewModeBtn) return;
if (isGridView) {
viewModeBtn.textContent = '\u2630';
viewModeBtn.title = L('files.switchToList', 'Switch to list view');
} else {
viewModeBtn.textContent = '\u25A6';
viewModeBtn.title = L('files.switchToGrid', 'Switch to grid view');
}
}
async function handleFileUploadInput(event) {
const files = event.target?.files;
if (!files || !files.length) return;
await handleFiles(files);
event.target.value = '';
}
async function handleFiles(fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
const formData = new FormData();
files.forEach((file) => {
const relativeName = file.webkitRelativePath || file.name;
formData.append('files[]', file, relativeName);
});
formData.append('currentPath', JSON.stringify(currentPath));
setStatus(L('files.uploadingCount', 'Uploading {{count}} file(s)...', { count: files.length }));
try {
const resp = await fetch('/upload_files', { method: 'POST', body: formData });
const data = await resp.json();
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
await loadAllFiles();
toast(L('files.uploadComplete', 'Upload complete'), 1800, 'success');
} catch (err) {
toast(`${L('files.uploadFailed', 'Upload failed')}: ${err.message}`, 3000, 'error');
setStatus(L('files.uploadFailed', 'Upload failed'));
}
}
function onDropZoneDragOver(e) {
e.preventDefault();
q('#drop-zone')?.classList.add('dragover');
}
function onDropZoneDragLeave() {
q('#drop-zone')?.classList.remove('dragover');
}
async function onDropZoneDrop(e) {
e.preventDefault();
q('#drop-zone')?.classList.remove('dragover');
const dt = e.dataTransfer;
if (!dt) return;
if (dt.items && dt.items.length && dt.items[0]?.webkitGetAsEntry) {
const files = await collectDroppedFiles(dt.items);
if (files.length) await handleFiles(files);
return;
}
if (dt.files && dt.files.length) await handleFiles(dt.files);
}
async function collectDroppedFiles(items) {
const files = [];
const entries = Array.from(items).map((i) => i.webkitGetAsEntry?.()).filter(Boolean);
async function walk(entry, path = '') {
if (entry.isFile) {
const file = await new Promise((resolve) => entry.file(resolve));
Object.defineProperty(file, 'webkitRelativePath', { value: path + entry.name, configurable: true });
files.push(file);
return;
}
if (!entry.isDirectory) return;
const reader = entry.createReader();
const children = await new Promise((resolve) => {
const acc = [];
function read() {
reader.readEntries((batch) => {
if (batch.length) {
acc.push(...batch);
read();
} else {
resolve(acc);
}
});
}
read();
});
const next = path + entry.name + '/';
for (const child of children) {
// eslint-disable-next-line no-await-in-loop
await walk(child, next);
}
}
for (const entry of entries) {
// eslint-disable-next-line no-await-in-loop
await walk(entry);
}
return files;
}
function buildRelativePath(fileName) {
return [...currentPath, fileName].filter(Boolean).join('/');
}
function buildAbsolutePath(relPath) {
const cleanRel = String(relPath || '').replace(/^\/+/, '').replace(/\\/g, '/');
if (!cleanRel) return absoluteBasePath;
return `${absoluteBasePath.replace(/\/+$/, '')}/${cleanRel}`;
}
function formatBytes(bytes, decimals = 1) {
const n = Number(bytes) || 0;
if (n <= 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function setStatus(msg) {
const status = q('#files-status');
if (status) status.textContent = String(msg || '');
}

556
web/js/pages/loot.js Normal file
View File

@@ -0,0 +1,556 @@
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'loot';
const MAC_IP_RE = /^[0-9a-f:]{17}_\d+\.\d+\.\d+\.\d+$/i;
let tracker = null;
let root = null;
let fileData = [];
let allFiles = [];
let currentView = 'tree';
let currentCategory = 'all';
let currentSort = 'name';
let sortDirection = 'asc';
let searchTerm = '';
let searchTimer = null;
const FILE_ICONS = {
ssh: '🔐',
sql: '🗄️',
smb: '🌐',
other: '📄',
};
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await loadFiles();
}
export function unmount() {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
root = null;
fileData = [];
allFiles = [];
currentView = 'tree';
currentCategory = 'all';
currentSort = 'name';
sortDirection = 'asc';
searchTerm = '';
}
function buildShell() {
return el('div', { class: 'loot-container' }, [
el('div', { class: 'stats-bar' }, [
statItem('👥', 'stat-victims', t('common.host')),
statItem('📄', 'stat-files', t('loot.totalFiles')),
statItem('📁', 'stat-folders', t('loot.directories')),
]),
el('div', { class: 'controls-bar' }, [
el('div', { class: 'search-container' }, [
el('span', { class: 'search-icon' }, ['🔍']),
el('input', {
type: 'text',
class: 'search-input',
id: 'searchInput',
placeholder: `${t('common.search')}...`,
}),
el('span', { class: 'clear-search', id: 'clearSearch' }, ['✖']),
]),
el('div', { class: 'view-controls' }, [
el('button', { class: 'view-btn active', id: 'treeViewBtn', title: 'Tree View', type: 'button' }, ['🌳']),
el('button', { class: 'view-btn', id: 'listViewBtn', title: t('common.list'), type: 'button' }, ['📋']),
el('div', { class: 'sort-dropdown', id: 'sortDropdown' }, [
el('button', { class: 'sort-btn', id: 'sortBtn', type: 'button', title: t('common.sortBy') }, ['⬇️']),
el('div', { class: 'sort-menu' }, [
sortOption('name', t('common.name'), true),
sortOption('type', t('common.type')),
sortOption('date', t('common.date')),
sortOption('asc', t('common.ascending')),
sortOption('desc', t('common.descending')),
]),
]),
]),
]),
el('div', { class: 'tabs-container', id: 'tabsContainer' }),
el('div', { class: 'explorer' }, [
el('div', { class: 'explorer-content', id: 'explorerContent' }, [
el('div', { class: 'loading' }, [
el('div', { class: 'loading-spinner' }),
]),
]),
]),
]);
}
function statItem(icon, id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-icon' }, [icon]),
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function sortOption(value, label, active = false) {
return el('div', {
class: `sort-option${active ? ' active' : ''}`,
'data-sort': value,
role: 'button',
tabindex: '0',
}, [label]);
}
function bindEvents() {
const searchInput = $('#searchInput', root);
const clearBtn = $('#clearSearch', root);
const treeBtn = $('#treeViewBtn', root);
const listBtn = $('#listViewBtn', root);
const sortDropdown = $('#sortDropdown', root);
const sortBtn = $('#sortBtn', root);
if (searchInput) {
tracker.trackEventListener(searchInput, 'input', (e) => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTerm = String(e.target.value || '').toLowerCase().trim();
renderContent(true);
}, 300);
});
}
if (clearBtn) {
tracker.trackEventListener(clearBtn, 'click', () => {
if (searchInput) searchInput.value = '';
searchTerm = '';
renderContent();
});
}
if (treeBtn) tracker.trackEventListener(treeBtn, 'click', () => setView('tree'));
if (listBtn) tracker.trackEventListener(listBtn, 'click', () => setView('list'));
if (sortBtn && sortDropdown) {
tracker.trackEventListener(sortBtn, 'click', () => {
sortDropdown.classList.toggle('active');
});
}
$$('.sort-option', root).forEach((option) => {
tracker.trackEventListener(option, 'click', () => onSortOption(option));
tracker.trackEventListener(option, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortOption(option);
}
});
});
tracker.trackEventListener(document, 'click', (e) => {
const dropdown = $('#sortDropdown', root);
if (dropdown && !dropdown.contains(e.target)) {
dropdown.classList.remove('active');
}
});
}
function onSortOption(option) {
$$('.sort-option', root).forEach((opt) => opt.classList.remove('active'));
option.classList.add('active');
const value = option.dataset.sort;
if (value === 'asc' || value === 'desc') {
sortDirection = value;
} else {
currentSort = value;
}
$('#sortDropdown', root)?.classList.remove('active');
renderContent();
}
function setView(view) {
currentView = view;
$$('.view-btn', root).forEach((btn) => btn.classList.remove('active'));
$(`#${view}ViewBtn`, root)?.classList.add('active');
renderContent();
}
async function loadFiles() {
try {
const data = await api.get('/loot_directories', { timeout: 15000 });
if (!data || data.status !== 'success' || !Array.isArray(data.data)) {
throw new Error('Invalid response');
}
fileData = data.data;
processFiles();
updateStats();
renderContent();
} catch (err) {
const explorer = $('#explorerContent', root);
if (!explorer) return;
empty(explorer);
explorer.appendChild(noResults('⚠️', `${t('common.error')}: ${t('common.noData')}`));
}
}
function processFiles() {
allFiles = [];
const stats = {};
function extractFiles(items, path = '') {
for (const item of items || []) {
if (item.type === 'directory' && Array.isArray(item.children)) {
extractFiles(item.children, `${path}${item.name}/`);
} else if (item.type === 'file') {
const category = getFileCategory(item.name, path);
const fullPath = `${path}${item.name}`;
allFiles.push({
...item,
category,
fullPath,
path: item.path || fullPath,
});
stats[category] = (stats[category] || 0) + 1;
}
}
}
extractFiles(fileData);
renderTabs(Object.keys(stats));
const allBadge = $('#badge-all', root);
if (allBadge) allBadge.textContent = String(allFiles.length);
for (const cat of Object.keys(stats)) {
const badge = $(`#badge-${cat}`, root);
if (badge) badge.textContent = String(stats[cat]);
}
}
function getFileCategory(filename, path) {
const lowerName = String(filename || '').toLowerCase();
const lowerPath = String(path || '').toLowerCase();
if (lowerPath.includes('ssh') || lowerName.includes('ssh') || lowerName.includes('key')) return 'ssh';
if (lowerPath.includes('sql') || lowerName.includes('sql') || lowerName.includes('database')) return 'sql';
if (lowerPath.includes('smb') || lowerName.includes('smb') || lowerName.includes('share')) return 'smb';
return 'other';
}
function getDirCategory(path) {
const lowerPath = String(path || '').toLowerCase();
if (lowerPath.includes('ssh')) return 'ssh';
if (lowerPath.includes('sql')) return 'sql';
if (lowerPath.includes('smb')) return 'smb';
return 'other';
}
function updateStats() {
const victims = new Set();
let totalFiles = 0;
let totalFolders = 0;
function scan(items) {
for (const item of items || []) {
if (item.type === 'directory') {
totalFolders += 1;
if (MAC_IP_RE.test(String(item.name || ''))) victims.add(item.name);
if (Array.isArray(item.children)) scan(item.children);
} else if (item.type === 'file') {
totalFiles += 1;
}
}
}
scan(fileData);
setText('stat-victims', victims.size);
setText('stat-files', totalFiles);
setText('stat-folders', totalFolders);
}
function setText(id, value) {
const node = $(`#${id}`, root);
if (node) node.textContent = String(value ?? '');
}
function fileMatchesSearch(file) {
if (!searchTerm) return true;
const n = String(file?.name || '').toLowerCase();
const p = String(file?.fullPath || '').toLowerCase();
return n.includes(searchTerm) || p.includes(searchTerm);
}
function computeSearchFilteredFiles() {
return allFiles.filter(fileMatchesSearch);
}
function updateBadgesFromFiltered() {
const filtered = computeSearchFilteredFiles();
setText('badge-all', filtered.length);
const byCat = filtered.reduce((acc, f) => {
acc[f.category] = (acc[f.category] || 0) + 1;
return acc;
}, {});
$$('.tab', root).forEach((tab) => {
const cat = tab.dataset.category;
if (cat === 'all') return;
setText(`badge-${cat}`, byCat[cat] || 0);
});
}
function renderTabs(categories) {
const tabs = $('#tabsContainer', root);
if (!tabs) return;
empty(tabs);
tabs.appendChild(tabNode('all', 'All', true));
for (const cat of categories) {
tabs.appendChild(tabNode(cat, cat.toUpperCase(), false));
}
$$('.tab', tabs).forEach((tab) => {
tracker.trackEventListener(tab, 'click', () => {
$$('.tab', tabs).forEach((tEl) => tEl.classList.remove('active'));
tab.classList.add('active');
currentCategory = tab.dataset.category;
renderContent();
});
});
}
function tabNode(category, label, active) {
return el('div', {
class: `tab${active ? ' active' : ''}`,
'data-category': category,
}, [
label,
el('span', { class: 'tab-badge', id: `badge-${category}` }, ['0']),
]);
}
function renderContent(autoExpand = false) {
const container = $('#explorerContent', root);
if (!container) return;
if (currentView === 'tree') {
renderTreeView(container, autoExpand);
} else {
renderListView(container);
}
}
function renderTreeView(container, autoExpand = false) {
updateBadgesFromFiltered();
const filteredData = filterDataForTree();
empty(container);
if (!filteredData.length) {
container.appendChild(noResults('🔍', t('common.noData')));
return;
}
const tree = el('div', { class: 'tree-view active' });
tree.appendChild(renderTreeItems(filteredData, 0, '', autoExpand || !!searchTerm));
container.appendChild(tree);
}
function filterDataForTree() {
function filterItems(items, path = '', isRoot = false) {
return (items || [])
.map((item) => {
if (item.type === 'directory') {
const dirPath = `${path}${item.name}/`;
const dirCategory = getDirCategory(dirPath);
const filteredChildren = Array.isArray(item.children)
? filterItems(item.children, dirPath, false)
: [];
const nameMatch = String(item.name || '').toLowerCase().includes(searchTerm);
if (isRoot) {
if (currentCategory !== 'all' && dirCategory !== currentCategory) return null;
if (!searchTerm) return { ...item, children: filteredChildren };
if (filteredChildren.length > 0 || nameMatch) return { ...item, children: filteredChildren };
return null;
}
if (nameMatch || filteredChildren.length > 0) {
return { ...item, children: filteredChildren };
}
return null;
}
if (item.type === 'file') {
const category = getFileCategory(item.name, path);
const temp = {
...item,
category,
fullPath: `${path}${item.name}`,
path: item.path || `${path}${item.name}`,
};
const matchesSearch = fileMatchesSearch(temp);
const matchesCategory = currentCategory === 'all' || category === currentCategory;
return matchesSearch && matchesCategory ? temp : null;
}
return null;
})
.filter(Boolean);
}
return filterItems(fileData, '', true);
}
function renderTreeItems(items, level, path = '', expanded = false) {
const frag = document.createDocumentFragment();
items.forEach((item, index) => {
if (item.type === 'directory') {
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const treeItem = el('div', { class: `tree-item${expanded ? ' expanded' : ''}` });
treeItem.style.animationDelay = `${index * 0.05}s`;
const header = el('div', { class: 'tree-header' }, [
el('div', { class: 'tree-icon folder-icon' }, ['📁']),
el('div', { class: 'tree-name' }, [item.name]),
]);
if (hasChildren) {
header.appendChild(el('div', { class: 'tree-chevron' }, ['▶']));
}
tracker.trackEventListener(header, 'click', (e) => {
e.stopPropagation();
treeItem.classList.toggle('expanded');
});
treeItem.appendChild(header);
if (hasChildren) {
const children = el('div', { class: 'tree-children' });
children.appendChild(renderTreeItems(item.children, level + 1, `${path}${item.name}/`, expanded));
treeItem.appendChild(children);
}
frag.appendChild(treeItem);
return;
}
if (item.type === 'file') {
const category = getFileCategory(item.name, path);
frag.appendChild(renderFileItem({
...item,
category,
fullPath: `${path}${item.name}`,
path: item.path || `${path}${item.name}`,
}, category, index, false));
}
});
return frag;
}
function renderListView(container) {
updateBadgesFromFiltered();
let filtered = allFiles.filter((f) => fileMatchesSearch(f) && (currentCategory === 'all' || f.category === currentCategory));
filtered.sort((a, b) => {
let res = 0;
switch (currentSort) {
case 'type':
res = a.category.localeCompare(b.category) || a.name.localeCompare(b.name);
break;
case 'date':
res = fileTimestamp(a) - fileTimestamp(b);
break;
case 'name':
default:
res = String(a.name || '').localeCompare(String(b.name || ''));
break;
}
return sortDirection === 'desc' ? -res : res;
});
empty(container);
if (!filtered.length) {
container.appendChild(noResults('🔍', t('common.noData')));
return;
}
const list = el('div', { class: 'list-view active' });
filtered.forEach((file, index) => {
list.appendChild(renderFileItem(file, file.category, index, true));
});
container.appendChild(list);
}
function fileTimestamp(file) {
const candidates = [
file?.modified,
file?.modified_at,
file?.date,
file?.mtime,
file?.created_at,
];
for (const v of candidates) {
if (v == null || v === '') continue;
if (typeof v === 'number' && Number.isFinite(v)) return v;
const ts = Date.parse(String(v));
if (Number.isFinite(ts)) return ts;
}
return 0;
}
function renderFileItem(file, category, index = 0, showPath = false) {
const path = file.path || file.fullPath || file.name;
const item = el('div', { class: 'file-item', 'data-path': path });
item.style.animationDelay = `${index * 0.02}s`;
tracker.trackEventListener(item, 'click', () => {
downloadFile(path);
});
const icon = el('div', { class: `file-icon ${category}` }, [FILE_ICONS[category] || FILE_ICONS.other]);
const name = el('div', { class: 'file-name' }, [String(file.name || '')]);
if (showPath) {
name.appendChild(el('span', { style: 'color:var(--_muted);font-size:0.75rem' }, [`${file.fullPath || path}`]));
}
const type = el('span', { class: `file-type ${category}` }, [String(category || 'other')]);
item.append(icon, name, type);
return item;
}
function downloadFile(path) {
window.location.href = `/loot_download?path=${encodeURIComponent(path)}`;
}
function noResults(icon, message) {
return el('div', { class: 'no-results' }, [
el('div', { class: 'no-results-icon' }, [icon]),
String(message || t('common.noData')),
]);
}

448
web/js/pages/netkb.js Normal file
View File

@@ -0,0 +1,448 @@
/**
* NetKB (Network Knowledge Base) page module.
* Displays discovered hosts with ports, actions, search, sort, filter, and 3 view modes.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'netkb';
const L = (key, fallback, vars = {}) => {
const v = t(key, vars);
return v === key ? fallback : v;
};
/* ── state ── */
let tracker = null;
let poller = null;
let originalData = [];
let viewMode = 'grid';
let showNotAlive = false;
let currentSort = 'ip';
let sortOrder = 1;
let currentFilter = null;
let searchTerm = '';
let searchDebounce = null;
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
const setPref = (k, v) => { try { localStorage.setItem(k, v); } catch { /* noop */ } };
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const savedView = getPref('netkb:view', isMobile() ? 'list' : 'grid');
const savedOffline = getPref('netkb:offline', 'false') === 'true';
const savedSearch = getPref('netkb:search', '');
viewMode = isMobile() && savedView === 'grid' ? 'list' : savedView;
showNotAlive = savedOffline;
if (savedSearch) searchTerm = savedSearch.toLowerCase();
container.appendChild(buildShell(savedSearch));
syncViewUI();
syncOfflineUI();
syncClearBtn();
tracker.trackEventListener(window, 'resize', () => {
if (isMobile() && viewMode === 'grid') { viewMode = 'list'; syncViewUI(); refreshDisplay(); }
});
/* close search popover on outside click */
tracker.trackEventListener(document, 'click', (e) => {
const pop = $('#netkb-searchPop');
const btn = $('#netkb-btnSearch');
if (pop && btn && !pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('show');
});
tracker.trackEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') { const pop = $('#netkb-searchPop'); if (pop) pop.classList.remove('show'); }
});
await refresh();
poller = new Poller(refresh, 5000);
poller.start();
}
export function unmount() {
clearTimeout(searchDebounce);
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
originalData = [];
searchTerm = '';
currentFilter = null;
}
/* ── data fetch ── */
async function refresh() {
try {
const data = await api.get('/netkb_data', { timeout: 8000 });
originalData = Array.isArray(data) ? data : [];
refreshDisplay();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
/* ── shell ── */
function buildShell(savedSearch) {
return el('div', { class: 'netkb-container' }, [
el('div', { class: 'netkb-toolbar-wrap' }, [
el('div', { class: 'netkb-toolbar', id: 'netkb-toolbar' }, [
el('button', {
class: 'icon-btn', id: 'netkb-btnSearch', title: t('common.search'),
onclick: toggleSearchPop
}, ['\u{1F50D}']),
el('div', { class: 'search-pop', id: 'netkb-searchPop' }, [
el('div', { class: 'search-input-wrap' }, [
el('input', {
type: 'text', id: 'netkb-searchInput',
placeholder: t('netkb.searchPlaceholder'),
title: t('netkb.searchHint'),
value: savedSearch || '', oninput: onSearchInput
}),
el('button', {
class: 'search-clear', id: 'netkb-searchClear', type: 'button',
'aria-label': 'Clear', onclick: clearSearch
}, ['\u2715']),
]),
el('div', { class: 'search-hint' }, [t('netkb.searchHint')]),
]),
el('div', { class: 'segmented', id: 'netkb-viewSeg' }, [
el('button', { 'data-view': 'grid', onclick: () => setView('grid') }, [L('common.grid', 'Grid')]),
el('button', { 'data-view': 'list', onclick: () => setView('list') }, [L('common.list', 'List')]),
el('button', { 'data-view': 'table', onclick: () => setView('table') }, [L('common.table', 'Table')]),
]),
el('label', { class: 'kb-switch', id: 'netkb-offlineSwitch', 'data-on': String(showNotAlive) }, [
el('input', {
type: 'checkbox', id: 'netkb-toggleOffline',
...(showNotAlive ? { checked: '' } : {}),
onchange: (e) => setOffline(e.target.checked)
}),
el('span', {}, [L('netkb.showOffline', 'Show offline')]),
el('span', { class: 'track' }, [el('span', { class: 'thumb' })]),
]),
]),
]),
el('div', { class: 'netkb-content' }, [
el('div', { id: 'netkb-card-container', class: 'card-container' }),
el('div', { id: 'netkb-table-container', class: 'table-wrap hidden' }),
]),
]);
}
/* ── search ── */
function toggleSearchPop() {
const pop = $('#netkb-searchPop');
if (!pop) return;
pop.classList.toggle('show');
if (pop.classList.contains('show')) {
const inp = $('#netkb-searchInput');
if (inp) { inp.focus(); inp.select(); }
}
}
function onSearchInput(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('netkb:search', e.target.value.trim());
refreshDisplay();
syncClearBtn();
}, 120);
}
function clearSearch() {
const inp = $('#netkb-searchInput');
if (inp) { inp.value = ''; inp.focus(); }
searchTerm = '';
setPref('netkb:search', '');
refreshDisplay();
syncClearBtn();
}
function syncClearBtn() {
const btn = $('#netkb-searchClear');
if (btn) btn.style.display = searchTerm ? '' : 'none';
}
/* ── view mode ── */
function setView(mode) {
if (isMobile() && mode === 'grid') mode = 'list';
viewMode = mode;
setPref('netkb:view', mode);
syncViewUI();
refreshDisplay();
}
function syncViewUI() {
const cards = $('#netkb-card-container');
const table = $('#netkb-table-container');
if (!cards || !table) return;
if (viewMode === 'table') {
cards.classList.add('hidden');
table.classList.remove('hidden');
} else {
table.classList.add('hidden');
cards.classList.remove('hidden');
}
$$('#netkb-viewSeg button').forEach(b => {
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
});
}
/* ── offline toggle ── */
function setOffline(on) {
showNotAlive = !!on;
syncOfflineUI();
setPref('netkb:offline', String(on));
refreshDisplay();
}
function syncOfflineUI() {
const sw = $('#netkb-offlineSwitch');
if (sw) sw.dataset.on = String(showNotAlive);
const cb = $('#netkb-toggleOffline');
if (cb) cb.checked = showNotAlive;
}
/* ── sort / filter ── */
function sortBy(key) {
if (currentSort === key) sortOrder = -sortOrder;
else { currentSort = key; sortOrder = 1; }
refreshDisplay();
}
function filterBy(criteria, ev) {
if (ev) ev.stopPropagation();
currentFilter = (currentFilter === criteria) ? null : criteria;
refreshDisplay();
}
/* ── paint orchestrator ── */
function refreshDisplay() {
let data = [...originalData];
if (searchTerm) data = data.filter(matchesSearch);
if (currentFilter) {
data = data.filter(item => {
switch (currentFilter) {
case 'hasActions': return item.actions && item.actions.some(a => a && a.status);
case 'hasPorts': return item.ports && item.ports.some(Boolean);
case 'toggleAlive': return !item.alive;
default: return true;
}
});
}
if (currentSort) {
const ipToNum = ip => !ip ? 0 : ip.split('.').reduce((a, p) => (a << 8) + (+p || 0), 0);
data.sort((a, b) => {
if (currentSort === 'ports') {
return sortOrder * ((a.ports?.filter(Boolean).length || 0) - (b.ports?.filter(Boolean).length || 0));
}
if (currentSort === 'ip') return sortOrder * (ipToNum(a.ip) - ipToNum(b.ip));
const av = (a[currentSort] || '').toString();
const bv = (b[currentSort] || '').toString();
return sortOrder * av.localeCompare(bv, undefined, { numeric: true });
});
}
if (viewMode === 'table') renderTable(data);
else renderCards(data);
}
/* ── search ── */
const norm = v => (v ?? '').toString().toLowerCase();
function matchesSearch(item) {
if (!searchTerm) return true;
const q = searchTerm;
if (norm(item.hostname).includes(q)) return true;
if (norm(item.ip).includes(q)) return true;
if (norm(item.mac).includes(q)) return true;
if (norm(item.vendor).includes(q)) return true;
if (norm(item.essid).includes(q)) return true;
if (Array.isArray(item.ports) && item.ports.some(p => norm(p).includes(q))) return true;
if (Array.isArray(item.actions) && item.actions.some(a => norm(a?.name).includes(q))) return true;
return false;
}
/* ── card rendering ── */
function renderCards(data) {
const container = $('#netkb-card-container');
if (!container) return;
empty(container);
const visible = data.filter(i => showNotAlive || i.alive);
if (visible.length === 0) {
container.appendChild(el('div', { class: 'netkb-empty' }, [t('common.noData')]));
return;
}
for (const item of visible) {
const alive = item.alive;
const cardClass = `card ${viewMode === 'list' ? 'list' : ''} ${alive ? 'alive' : 'not-alive'}`;
const title = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A');
const sections = [];
if (item.ip) sections.push(fieldRow('IP', 'ip', item.ip));
if (item.mac) sections.push(fieldRow('MAC', 'mac', item.mac));
if (item.vendor && item.vendor !== 'N/A') sections.push(fieldRow('Vendor', 'vendor', item.vendor));
if (item.essid && item.essid !== 'N/A') sections.push(fieldRow('ESSID', 'essid', item.essid));
if (item.ports && item.ports.filter(Boolean).length > 0) {
sections.push(el('div', { class: 'card-section' }, [
el('strong', {}, [L('netkb.openPorts', 'Open Ports') + ':']),
el('div', { class: 'port-bubbles' },
item.ports.filter(Boolean).map(p => chip('port', String(p)))
),
]));
}
container.appendChild(el('div', { class: cardClass }, [
el('div', { class: 'card-content' }, [
el('h3', { class: 'card-title' }, [hlText(title)]),
...sections,
]),
el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip)),
]));
}
}
/* ── table rendering ── */
function renderTable(data) {
const container = $('#netkb-table-container');
if (!container) return;
empty(container);
const thClick = (key) => () => sortBy(key);
const fClick = (crit) => (e) => filterBy(crit, e);
const thead = el('thead', {}, [
el('tr', {}, [
el('th', { onclick: thClick('hostname') }, [t('common.hostname') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('toggleAlive'), title: 'Toggle offline', alt: 'Filter' })]),
el('th', { onclick: thClick('ip') }, ['IP']),
el('th', { onclick: thClick('mac') }, ['MAC']),
el('th', { onclick: thClick('essid') }, ['ESSID']),
el('th', { onclick: thClick('vendor') }, [t('common.vendor')]),
el('th', { onclick: thClick('ports') }, [t('common.ports') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasPorts'), title: 'Has ports', alt: 'Filter' })]),
el('th', {}, [t('common.actions') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasActions'), title: 'Has actions', alt: 'Filter' })]),
]),
]);
const visible = data.filter(i => showNotAlive || i.alive);
const rows = visible.map(item => {
const hostText = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A');
return el('tr', {}, [
el('td', {}, [chip('host', hostText)]),
el('td', {}, item.ip ? [chip('ip', item.ip)] : ['N/A']),
el('td', {}, item.mac ? [chip('mac', item.mac)] : ['N/A']),
el('td', {}, (item.essid && item.essid !== 'N/A') ? [chip('essid', item.essid)] : ['N/A']),
el('td', {}, (item.vendor && item.vendor !== 'N/A') ? [chip('vendor', item.vendor)] : ['N/A']),
el('td', {}, [el('div', { class: 'port-bubbles' },
(item.ports || []).filter(Boolean).map(p => chip('port', String(p))))]),
el('td', {}, [el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip))]),
]);
});
container.appendChild(el('div', { class: 'table-inner' }, [
el('table', {}, [thead, el('tbody', {}, rows)]),
]));
}
/* ── action badges ── */
function renderBadges(actions, ip) {
if (!actions || actions.length === 0) return [];
const parseRaw = (raw) => {
const m = /^([a-z_]+)_(\d{8})_(\d{6})$/i.exec(raw || '');
if (!m) return null;
const s = m[1].toLowerCase();
const y = m[2].slice(0, 4), mo = m[2].slice(4, 6), d = m[2].slice(6, 8);
const hh = m[3].slice(0, 2), mm = m[3].slice(2, 4), ss = m[3].slice(4, 6);
const ts = Date.parse(`${y}-${mo}-${d}T${hh}:${mm}:${ss}Z`) || 0;
return { status: s, ts, d, mo, y, hh, mm, ss };
};
const map = new Map();
for (const a of actions) {
if (!a || !a.name || !a.status) continue;
const p = parseRaw(a.status);
if (!p) continue;
const prev = map.get(a.name);
if (!prev || p.ts > prev.parsed.ts) map.set(a.name, { ...a, parsed: p });
}
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const label = s => ({ success: 'Success', failed: 'Failed', fail: 'Failed', running: 'Running', pending: 'Pending', expired: 'Expired', cancelled: 'Cancelled' })[s] || s;
return Array.from(map.values())
.sort((a, b) => b.parsed.ts - a.parsed.ts)
.map(a => {
const s = a.parsed.status === 'fail' ? 'failed' : a.parsed.status;
const clickable = ['success', 'failed', 'expired', 'cancelled'].includes(s);
const date = `${a.parsed.d} ${MONTHS[parseInt(a.parsed.mo) - 1] || ''} ${a.parsed.y}`;
const time = `${a.parsed.hh}:${a.parsed.mm}:${a.parsed.ss}`;
return el('div', {
class: `badge ${s} ${clickable ? 'clickable' : ''}`,
...(clickable ? {
onclick: () => {
if (!confirm(L('netkb.confirmRemoveAction', `Are you sure you want to remove the action "${a.name}" for IP "${ip}"?`, { action: a.name, ip }))) return;
removeAction(ip, a.name);
}
} : {}),
}, [
el('div', { class: 'badge-header' }, [hlText(a.name)]),
el('div', { class: 'badge-status' }, [label(s)]),
el('div', { class: 'badge-timestamp' }, [el('div', {}, [date]), el('div', {}, [`at ${time}`])]),
]);
});
}
async function removeAction(ip, action) {
try {
const result = await api.post('/delete_netkb_action', { ip, action });
if (result.status === 'success') {
toast(result.message || t('netkb.actionRemoved'), 2600, 'success');
await refresh();
} else throw new Error(result.message || 'Failed');
} catch (e) {
console.error(e);
toast(`${t('common.error')}: ${e.message}`, 3000, 'error');
}
}
/* ── helpers ── */
function chip(type, text) {
return el('span', { class: `chip ${type}` }, [hlText(text)]);
}
function fieldRow(label, chipType, value) {
return el('div', { class: 'card-section' }, [
el('strong', {}, [`${label}:`]),
el('span', {}, [' ']),
chip(chipType, value),
]);
}
function hlText(text) {
if (!searchTerm || !text) return String(text ?? '');
const str = String(text);
const lower = str.toLowerCase();
const idx = lower.indexOf(searchTerm);
if (idx === -1) return str;
const frag = document.createDocumentFragment();
let pos = 0;
let i = lower.indexOf(searchTerm, pos);
while (i !== -1) {
if (i > pos) frag.appendChild(document.createTextNode(str.slice(pos, i)));
const mark = document.createElement('mark');
mark.className = 'hl';
mark.textContent = str.slice(i, i + searchTerm.length);
frag.appendChild(mark);
pos = i + searchTerm.length;
i = lower.indexOf(searchTerm, pos);
}
if (pos < str.length) frag.appendChild(document.createTextNode(str.slice(pos)));
return frag;
}
function isMobile() { return window.matchMedia('(max-width: 720px)').matches; }

566
web/js/pages/network.js Normal file
View File

@@ -0,0 +1,566 @@
/**
* Network page module.
* Table view + D3 force-directed map with zoom/drag, search, label toggle.
* Endpoint /network_data returns HTML, parsed client-side.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'network';
const L = (key, fallback, vars = {}) => {
const v = t(key, vars);
return v === key ? fallback : v;
};
const ICONS = {
bjorn: '/web/images/boat.png',
host_active: '/web/images/target.png',
host_empty: '/web/images/target2.png',
loot: '/web/images/treasure.png',
gateway: '/web/images/lighthouse.png',
};
/* ── state ── */
let tracker = null;
let poller = null;
let networkData = [];
let viewMode = 'table';
let showLabels = true;
let searchTerm = '';
let searchDebounce = null;
let currentSortState = { column: -1, direction: 'asc' };
/* D3 state */
let d3Module = null;
let simulation = null;
let svg = null;
let g = null;
let nodeGroup = null;
let linkGroup = null;
let labelsGroup = null;
let globalNodes = [];
let globalLinks = [];
let currentZoomScale = 1;
let mapInitialized = false;
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
const setPref = (k, v) => { try { localStorage.setItem(k, v); } catch { /* noop */ } };
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
viewMode = getPref('nv:view', 'table');
showLabels = getPref('nv:showHostname', 'true') === 'true';
const savedSearch = getPref('nv:search', '');
if (savedSearch) searchTerm = savedSearch.toLowerCase();
container.appendChild(buildShell(savedSearch));
syncViewUI();
syncClearBtn();
await refresh();
poller = new Poller(refresh, 5000);
poller.start();
}
export function unmount() {
clearTimeout(searchDebounce);
if (poller) { poller.stop(); poller = null; }
if (simulation) { simulation.stop(); simulation = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
networkData = [];
globalNodes = [];
globalLinks = [];
mapInitialized = false;
d3Module = null;
svg = null;
g = null;
nodeGroup = null;
linkGroup = null;
labelsGroup = null;
}
/* ── data fetch ── */
async function refresh() {
try {
const html = await api.get('/network_data', { timeout: 8000 });
if (typeof html !== 'string') return;
networkData = parseNetworkHTML(html);
renderTable();
applySearchToTable();
if (mapInitialized) updateMapFromData(networkData);
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
/* ── parse HTML response ── */
function parseNetworkHTML(htmlStr) {
const tmp = document.createElement('div');
tmp.innerHTML = htmlStr;
const table = tmp.querySelector('table');
if (!table) return [];
const rows = Array.from(table.querySelectorAll('tr')).slice(1);
return rows.map(tr => {
const cells = Array.from(tr.querySelectorAll('td'));
if (cells.length < 6) return null;
const essid = (cells[0]?.textContent || '').trim();
const ip = (cells[1]?.textContent || '').trim();
const hostname = (cells[2]?.textContent || '').trim();
const mac = (cells[3]?.textContent || '').trim();
const vendor = (cells[4]?.textContent || '').trim();
const portsStr = (cells[5]?.textContent || '').trim();
const ports = portsStr.split(';').map(p => p.trim()).filter(p => p && p.toLowerCase() !== 'none');
return { essid, ip, hostname, mac, vendor, ports };
}).filter(Boolean);
}
/* ── shell ── */
function buildShell(savedSearch) {
return el('div', { class: 'network-container' }, [
el('div', { class: 'ocean-container' }, [
el('div', { class: 'ocean-surface' }),
el('div', { class: 'ocean-caustics' }),
]),
el('div', { class: 'nv-toolbar-wrap' }, [
el('div', { class: 'nv-toolbar' }, [
el('div', { class: 'nv-search' }, [
el('span', { class: 'nv-search-icon', 'aria-hidden': 'true' }, ['\u{1F50D}']),
el('input', {
type: 'text', id: 'searchInput', placeholder: t('common.search'),
value: savedSearch || '', oninput: onSearchInput
}),
el('button', {
class: 'nv-search-clear', id: 'nv-searchClear', type: 'button',
'aria-label': 'Clear', onclick: clearSearch
}, ['\u2715']),
]),
el('div', { class: 'segmented', id: 'viewSeg' }, [
el('button', { 'data-view': 'table', onclick: () => setView('table') }, [L('common.table', 'Table')]),
el('button', { 'data-view': 'map', onclick: () => setView('map') }, [L('common.map', 'Map')]),
]),
el('label', {
class: 'nv-switch', id: 'hostSwitch',
'data-on': String(showLabels),
style: viewMode === 'map' ? '' : 'display:none'
}, [
el('input', {
type: 'checkbox', id: 'toggleHostname',
...(showLabels ? { checked: '' } : {}),
onchange: (e) => toggleLabels(e.target.checked)
}),
el('span', {}, [L('network.showHostname', 'Show hostname')]),
el('span', { class: 'track' }, [el('span', { class: 'thumb' })]),
]),
]),
]),
el('div', { id: 'table-wrap', class: 'table-wrap' }, [
el('div', { id: 'network-table' }),
]),
el('div', { id: 'visualization-container', style: 'display:none' }),
el('div', { id: 'd3-tooltip', class: 'd3-tooltip' }),
]);
}
/* ── search ── */
function onSearchInput(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('nv:search', searchTerm);
applySearchToTable();
applySearchToMap();
syncClearBtn();
}, 120);
}
function clearSearch() {
const inp = $('#searchInput');
if (inp) { inp.value = ''; inp.focus(); }
searchTerm = '';
setPref('nv:search', '');
applySearchToTable();
applySearchToMap();
syncClearBtn();
}
function syncClearBtn() {
const btn = $('#nv-searchClear');
if (btn) btn.style.display = searchTerm ? '' : 'none';
}
function applySearchToTable() {
const table = document.querySelector('#network-table table');
if (!table) return;
const rows = Array.from(table.querySelectorAll('tbody tr'));
rows.forEach(tr => {
tr.style.display = !searchTerm || tr.textContent.toLowerCase().includes(searchTerm) ? '' : 'none';
});
}
function applySearchToMap() {
if (!d3Module || !nodeGroup) return;
nodeGroup.selectAll('.node').style('opacity', d => {
if (!searchTerm) return 1;
const bag = `${d.label} ${d.ip || ''} ${d.vendor || ''}`.toLowerCase();
return bag.includes(searchTerm) ? 1 : 0.1;
});
}
/* ── view ── */
function setView(mode) {
viewMode = mode;
setPref('nv:view', mode);
syncViewUI();
if (mode === 'map' && !mapInitialized) initMap();
}
function syncViewUI() {
const tableWrap = $('#table-wrap');
const mapContainer = $('#visualization-container');
const hostSwitch = $('#hostSwitch');
if (tableWrap) tableWrap.style.display = viewMode === 'table' ? 'block' : 'none';
if (mapContainer) mapContainer.style.display = viewMode === 'map' ? 'block' : 'none';
if (hostSwitch) hostSwitch.style.display = viewMode === 'map' ? 'inline-flex' : 'none';
$$('#viewSeg button').forEach(b => {
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
});
}
/* ── labels ── */
function toggleLabels(on) {
showLabels = on;
setPref('nv:showHostname', String(on));
const sw = $('#hostSwitch');
if (sw) sw.dataset.on = String(on);
if (labelsGroup) labelsGroup.style('opacity', showLabels ? 1 : 0);
}
/* ── table rendering ── */
function renderTable() {
const wrap = $('#network-table');
if (!wrap) return;
empty(wrap);
if (networkData.length === 0) {
wrap.appendChild(el('div', { class: 'network-empty' }, [t('common.noData')]));
return;
}
const thead = el('thead', {}, [
el('tr', {}, [
el('th', { class: 'hosts-header' }, [L('common.hosts', 'Hosts')]),
el('th', {}, [L('common.ports', 'Ports')]),
]),
]);
const rows = networkData.map(item => {
const hostBubbles = [];
if (item.ip) hostBubbles.push(el('span', { class: 'bubble ip-address' }, [item.ip]));
if (item.hostname) hostBubbles.push(el('span', { class: 'bubble hostname' }, [item.hostname]));
if (item.mac) hostBubbles.push(el('span', { class: 'bubble mac-address' }, [item.mac]));
if (item.vendor) hostBubbles.push(el('span', { class: 'bubble vendor' }, [item.vendor]));
if (item.essid) hostBubbles.push(el('span', { class: 'bubble essid' }, [item.essid]));
const portBubbles = item.ports.map(p => el('span', { class: 'port-bubble' }, [p]));
return el('tr', {}, [
el('td', { class: 'hosts-cell' }, [el('div', { class: 'hosts-content' }, hostBubbles)]),
el('td', {}, [el('div', { class: 'ports-container' }, portBubbles)]),
]);
});
const table = el('table', { class: 'network-table' }, [thead, el('tbody', {}, rows)]);
wrap.appendChild(el('div', { class: 'table-inner' }, [table]));
/* table sort */
initTableSorting(table);
}
function initTableSorting(table) {
const headers = Array.from(table.querySelectorAll('th'));
headers.forEach((h, idx) => {
h.style.cursor = 'pointer';
h.addEventListener('click', () => {
headers.forEach(x => x.classList.remove('sort-asc', 'sort-desc'));
if (currentSortState.column === idx) {
currentSortState.direction = currentSortState.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSortState.column = idx;
currentSortState.direction = 'asc';
}
h.classList.add(`sort-${currentSortState.direction}`);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const A = a.querySelectorAll('td')[idx]?.textContent.trim().toLowerCase() || '';
const B = b.querySelectorAll('td')[idx]?.textContent.trim().toLowerCase() || '';
return currentSortState.direction === 'asc' ? A.localeCompare(B) : B.localeCompare(A);
});
rows.forEach(r => tbody.appendChild(r));
});
});
}
/* ── D3 Map ── */
async function initMap() {
const container = $('#visualization-container');
if (!container) return;
/* lazy load d3 from local static file (CSP-safe) */
if (!d3Module) {
try {
d3Module = window.d3 || null;
if (!d3Module) {
await loadScriptOnce('/web/js/d3.v7.min.js');
d3Module = window.d3 || null;
}
if (!d3Module) throw new Error('window.d3 unavailable');
} catch (e) {
console.warn('[network] D3 not available:', e.message);
container.appendChild(el('div', { class: 'network-empty' }, ['D3 library not available for map view.']));
return;
}
}
const d3 = d3Module;
/* Force a layout recalc so clientWidth/clientHeight are up to date */
void container.offsetHeight;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
console.debug('[network] Map init: container', width, 'x', height);
svg = d3.select(container).append('svg')
.attr('width', width).attr('height', height)
.style('width', '100%').style('height', '100%');
/* click background to hide tooltip */
svg.on('click', () => {
const tt = $('#d3-tooltip');
if (tt) tt.style.opacity = '0';
});
g = svg.append('g');
/* layers */
g.append('g').attr('class', 'sonar-layer');
linkGroup = g.append('g').attr('class', 'links-layer');
nodeGroup = g.append('g').attr('class', 'nodes-layer');
labelsGroup = g.append('g').attr('class', 'labels-layer node-labels');
/* zoom */
const zoom = d3.zoom().scaleExtent([0.2, 6]).on('zoom', (e) => {
g.attr('transform', e.transform);
currentZoomScale = e.transform.k;
requestAnimationFrame(() =>
labelsGroup.selectAll('.label-group')
.attr('transform', d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`)
);
});
svg.call(zoom);
/* physics */
simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(d => d.target?.type === 'loot' ? 30 : 80))
.force('charge', d3.forceManyBody().strength(d => d.type === 'host_empty' ? -300 : -100))
.force('collide', d3.forceCollide().radius(d => d.r * 1.5).iterations(2))
.force('x', d3.forceX(width / 2).strength(0.08))
.force('y', d3.forceY(height / 2).strength(0.08))
.alphaMin(0.05)
.velocityDecay(0.6)
.on('tick', ticked);
tracker.trackEventListener(window, 'resize', () => {
if (viewMode !== 'map') return;
const w = container.clientWidth;
const h = container.clientHeight;
svg.attr('width', w).attr('height', h);
simulation.force('x', d3.forceX(w / 2).strength(0.08));
simulation.force('y', d3.forceY(h / 2).strength(0.08));
simulation.alpha(0.3).restart();
});
mapInitialized = true;
if (networkData.length > 0) updateMapFromData(networkData);
}
function loadScriptOnce(src) {
const existing = document.querySelector(`script[data-src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === '1') return Promise.resolve();
return new Promise((resolve, reject) => {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('Script load failed')), { once: true });
});
}
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.src = src;
s.addEventListener('load', () => {
s.dataset.loaded = '1';
resolve();
}, { once: true });
s.addEventListener('error', () => reject(new Error(`Script load failed: ${src}`)), { once: true });
document.head.appendChild(s);
});
}
function updateMapFromData(data) {
if (!d3Module || !simulation) return;
const incomingNodes = new Map();
const incomingLinks = [];
incomingNodes.set('bjorn', { id: 'bjorn', type: 'bjorn', r: 50, label: 'BJORN' });
data.forEach(h => {
const hasPorts = h.ports && h.ports.length > 0;
const isGateway = h.ip.endsWith('.1') || h.ip.endsWith('.254');
const type = isGateway ? 'gateway' : (hasPorts ? 'host_active' : 'host_empty');
const radius = isGateway ? 40 : (hasPorts ? 30 : 20);
incomingNodes.set(h.ip, {
id: h.ip, type, ip: h.ip, label: h.hostname || h.ip,
vendor: h.vendor, r: radius, ports: h.ports,
});
if (hasPorts) {
h.ports.forEach(p => {
const portId = `${h.ip}_${p}`;
incomingNodes.set(portId, { id: portId, type: 'loot', label: p, r: 15, parent: h.ip });
incomingLinks.push({ source: h.ip, target: portId });
});
}
});
/* reconcile */
const nextNodes = [];
let hasStructuralChanges = globalNodes.length !== incomingNodes.size;
incomingNodes.forEach((data, id) => {
const existing = globalNodes.find(n => n.id === id);
if (existing) {
if (existing.type !== data.type) hasStructuralChanges = true;
Object.assign(existing, data);
nextNodes.push(existing);
} else {
hasStructuralChanges = true;
const w = parseInt(svg.attr('width')) || 800;
const h = parseInt(svg.attr('height')) || 600;
data.x = w / 2 + (Math.random() - 0.5) * 50;
data.y = h / 2 + (Math.random() - 0.5) * 50;
nextNodes.push(data);
}
});
globalNodes = nextNodes;
globalLinks = incomingLinks.map(l => ({ source: l.source, target: l.target }));
updateViz(hasStructuralChanges);
}
function updateViz(restartSim) {
const d3 = d3Module;
/* nodes */
const node = nodeGroup.selectAll('.node').data(globalNodes, d => d.id);
const nodeEnter = node.enter().append('g').attr('class', 'node')
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }));
nodeEnter.append('g').attr('class', 'foam-container');
nodeEnter.append('image').attr('class', 'node-icon')
.on('error', function () { d3.select(this).style('display', 'none'); });
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.attr('class', d => `node ${d.type === 'host_empty' ? 'empty' : ''}`);
nodeUpdate.select('.node-icon')
.attr('xlink:href', d => ICONS[d.type] || ICONS.host_empty)
.attr('x', d => -d.r).attr('y', d => -d.r)
.attr('width', d => d.r * 2).attr('height', d => d.r * 2)
.style('display', 'block');
nodeUpdate.select('.foam-container').each(function (d) {
if (!['bjorn', 'gateway', 'host_active'].includes(d.type)) {
d3.select(this).selectAll('*').remove();
return;
}
if (d3.select(this).selectAll('circle').empty()) {
const c = d3.select(this);
[1, 2].forEach(i => c.append('circle').attr('class', 'foam-ring').attr('r', d.r * (1 + i * 0.15)));
}
});
nodeUpdate.on('click', (e, d) => showTooltip(e, d));
node.exit().transition().duration(500).style('opacity', 0).remove();
/* links */
const link = linkGroup.selectAll('.link').data(globalLinks, d =>
(d.source.id || d.source) + '-' + (d.target.id || d.target));
link.enter().append('line').attr('class', 'link');
link.exit().remove();
/* labels */
const labelData = globalNodes.filter(d => ['bjorn', 'gateway', 'host_active', 'loot'].includes(d.type));
const label = labelsGroup.selectAll('.label-group').data(labelData, d => d.id);
const labelEnter = label.enter().append('g').attr('class', 'label-group');
labelEnter.append('rect').attr('class', 'label-bg').attr('height', 16);
labelEnter.append('text').attr('class', 'label-text').attr('text-anchor', 'middle').attr('y', 11);
const labelUpdate = labelEnter.merge(label);
labelUpdate.select('text').text(d => d.label).each(function () {
const w = this.getBBox().width;
d3.select(this.parentNode).select('rect').attr('x', -w / 2 - 4).attr('width', w + 8);
});
label.exit().remove();
labelsGroup.style('opacity', showLabels ? 1 : 0);
simulation.nodes(globalNodes);
simulation.force('link').links(globalLinks);
if (restartSim) simulation.alpha(0.3).restart();
}
function ticked() {
linkGroup.selectAll('.link')
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
nodeGroup.selectAll('.node')
.attr('transform', d => `translate(${d.x},${d.y})`);
labelsGroup.selectAll('.label-group')
.attr('transform', d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`);
/* sonar on bjorn */
const bjorn = globalNodes.find(n => n.type === 'bjorn');
if (bjorn && g) {
let sonar = g.select('.sonar-layer').selectAll('.sonar-wave').data([bjorn]);
sonar.enter().append('circle').attr('class', 'sonar-wave')
.merge(sonar).attr('cx', d => d.x).attr('cy', d => d.y);
}
}
function showTooltip(e, d) {
e.stopPropagation();
const tt = $('#d3-tooltip');
if (!tt) return;
empty(tt);
if (d.type === 'loot') {
tt.appendChild(el('div', {}, [`\u{1F4B0} Port ${d.label}`]));
} else {
tt.appendChild(el('div', { style: 'color:var(--accent1);font-weight:bold;margin-bottom:5px' }, [d.label]));
if (d.ip && d.ip !== d.label) tt.appendChild(el('div', {}, [d.ip]));
if (d.vendor) tt.appendChild(el('div', { style: 'opacity:0.8;font-size:0.8em' }, [d.vendor]));
}
tt.style.left = (e.pageX + 10) + 'px';
tt.style.top = (e.pageY - 50) + 'px';
tt.style.opacity = '1';
}

View File

@@ -0,0 +1,614 @@
/**
* RL Dashboard - Abstract model cloud visualization.
* Canvas is intentionally NOT linked to current action execution.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, setText, empty } from '../core/dom.js';
let tracker = null;
let statsPoller = null;
let historyPoller = null;
let metricsGraph = null;
let modelCloud = null;
export async function mount(container) {
tracker = new ResourceTracker('rl-dashboard');
container.innerHTML = '';
container.appendChild(buildLayout());
await fetchStats();
await fetchHistory();
await fetchExperiences();
statsPoller = new Poller(fetchStats, 5000);
historyPoller = new Poller(async () => {
await fetchHistory();
await fetchExperiences();
}, 10000);
statsPoller.start();
historyPoller.start();
}
export function unmount() {
if (statsPoller) {
statsPoller.stop();
statsPoller = null;
}
if (historyPoller) {
historyPoller.stop();
historyPoller = null;
}
if (metricsGraph) {
metricsGraph.destroy();
metricsGraph = null;
}
if (modelCloud) {
modelCloud.destroy();
modelCloud = null;
}
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
}
/* ======================== Mini Metrics Canvas ======================== */
class MultiMetricGraph {
constructor(canvasId) {
this.data = {
epsilon: new Array(100).fill(0),
reward: new Array(100).fill(0),
loss: new Array(100).fill(0),
};
this.colors = {
epsilon: '#00d4ff',
reward: '#00ff6a',
loss: '#ff4169',
};
this.canvas = document.getElementById(canvasId);
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this._onResize = () => this.resize();
window.addEventListener('resize', this._onResize);
this.resize();
this.animate();
}
destroy() {
window.removeEventListener('resize', this._onResize);
if (this._raf) cancelAnimationFrame(this._raf);
}
resize() {
const p = this.canvas.parentElement;
this.canvas.width = Math.max(1, p.offsetWidth);
this.canvas.height = Math.max(1, p.offsetHeight);
this.width = this.canvas.width;
this.height = this.canvas.height;
}
update(stats) {
if (!stats) return;
this.data.epsilon.shift();
this.data.reward.shift();
this.data.loss.shift();
this.data.epsilon.push(Number(stats.epsilon || 0));
const recent = Array.isArray(stats.recent_activity) ? stats.recent_activity : [];
const r = recent.length ? Number(recent[0].reward || 0) : 0;
const prevR = this.data.reward[this.data.reward.length - 1] || 0;
this.data.reward.push(prevR * 0.8 + r * 0.2);
const l = Number(stats.last_loss || 0);
const prevL = this.data.loss[this.data.loss.length - 1] || 0;
this.data.loss.push(prevL * 0.9 + l * 0.1);
}
animate() {
this._raf = requestAnimationFrame(() => this.animate());
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawLine(this.data.epsilon, this.colors.epsilon, 1.0);
this.drawLine(this.data.reward, this.colors.reward, 10.0);
this.drawLine(this.data.loss, this.colors.loss, 5.0);
}
drawLine(data, color, maxVal) {
if (data.length < 2) return;
const stepX = this.width / (data.length - 1);
this.ctx.beginPath();
data.forEach((val, i) => {
const x = i * stepX;
const y = this.height - (Math.max(0, val) / Math.max(0.001, maxVal)) * this.height * 0.8 - 5;
if (i === 0) this.ctx.moveTo(x, y);
else this.ctx.lineTo(x, y);
});
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 2;
this.ctx.stroke();
}
}
/* ======================== Abstract Model Cloud ======================== */
class ModelCloud {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this.tooltip = document.getElementById('brain-tooltip');
this.nodes = [];
this.tick = 0;
this.hoverIndex = -1;
this.meta = {
model_loaded: false,
model_version: null,
model_param_count: 0,
model_layer_count: 0,
model_feature_count: 0,
};
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(this.canvas.parentElement);
this.resize();
this.onMouseMove = (e) => this.handleMouseMove(e);
this.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.addEventListener('mouseleave', () => {
this.hoverIndex = -1;
if (this.tooltip) this.tooltip.style.display = 'none';
});
this.reseedNodes(30);
this.animate();
}
destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.canvas && this.onMouseMove) this.canvas.removeEventListener('mousemove', this.onMouseMove);
if (this.raf) cancelAnimationFrame(this.raf);
}
resize() {
const p = this.canvas.parentElement;
this.width = Math.max(1, p.offsetWidth);
this.height = Math.max(1, p.offsetHeight);
this.canvas.width = this.width;
this.canvas.height = this.height;
}
updateFromStats(stats) {
this.meta = {
model_loaded: !!stats.model_loaded,
model_version: stats.model_version || null,
model_param_count: Number(stats.model_param_count || 0),
model_layer_count: Number(stats.model_layer_count || 0),
model_feature_count: Number(stats.model_feature_count || 0),
};
const nTarget = this.computeNodeTarget(this.meta);
this.adjustPopulation(nTarget);
this.updateNodeEncoding();
}
computeNodeTarget(meta) {
if (!meta.model_loaded) return 26;
const pScore = Math.log10(Math.max(10, meta.model_param_count));
const lScore = Math.max(1, meta.model_layer_count);
const fScore = Math.log10(Math.max(10, meta.model_feature_count * 100));
const raw = 18 + pScore * 14 + lScore * 2 + fScore * 8;
return Math.max(25, Math.min(180, Math.round(raw)));
}
reseedNodes(count) {
this.nodes = [];
for (let i = 0; i < count; i++) {
this.nodes.push(this.makeNode());
}
}
makeNode() {
const r = 2 + Math.random() * 4;
return {
x: Math.random() * this.width,
y: Math.random() * this.height,
vx: (Math.random() - 0.5) * 0.35,
vy: (Math.random() - 0.5) * 0.35,
r,
energy: 0.2 + Math.random() * 0.8,
phase: Math.random() * Math.PI * 2,
cluster: Math.floor(Math.random() * 4),
};
}
adjustPopulation(target) {
const current = this.nodes.length;
if (current < target) {
for (let i = 0; i < target - current; i++) this.nodes.push(this.makeNode());
} else if (current > target) {
this.nodes.length = target;
}
}
updateNodeEncoding() {
const layers = Math.max(1, this.meta.model_layer_count || 1);
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
n.cluster = i % layers;
n.energy = 0.25 + ((i % (layers + 3)) / (layers + 3));
n.r = 2 + (n.energy * 4.5);
}
}
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.hoverIndex = -1;
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
const dx = mx - n.x;
const dy = my - n.y;
if (dx * dx + dy * dy <= (n.r + 4) * (n.r + 4)) {
this.hoverIndex = i;
break;
}
}
if (!this.tooltip || this.hoverIndex < 0) {
if (this.tooltip) this.tooltip.style.display = 'none';
return;
}
const n = this.nodes[this.hoverIndex];
this.tooltip.style.display = 'block';
this.tooltip.innerHTML = `
<strong>Model Cloud Node</strong><br>
<span style="color:#9bb">Cluster ${n.cluster + 1}</span><br>
<span style="color:#00e7ff">Energy ${(n.energy * 100).toFixed(1)}%</span>
`;
const tx = Math.min(this.width - 180, mx + 12);
const ty = Math.min(this.height - 80, my + 12);
this.tooltip.style.left = `${Math.max(8, tx)}px`;
this.tooltip.style.top = `${Math.max(8, ty)}px`;
}
animate() {
this.raf = requestAnimationFrame(() => this.animate());
this.tick += 0.01;
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawLinks();
this.updateAndDrawNodes();
this.drawOverlay();
}
drawLinks() {
const maxDist = 70;
for (let i = 0; i < this.nodes.length; i++) {
const a = this.nodes[i];
for (let j = i + 1; j < this.nodes.length; j++) {
const b = this.nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > maxDist * maxDist) continue;
const d = Math.sqrt(d2);
const alpha = (1 - d / maxDist) * 0.2;
this.ctx.strokeStyle = `rgba(90,200,255,${alpha})`;
this.ctx.lineWidth = 0.6;
this.ctx.beginPath();
this.ctx.moveTo(a.x, a.y);
this.ctx.lineTo(b.x, b.y);
this.ctx.stroke();
}
}
}
updateAndDrawNodes() {
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
n.x += n.vx + Math.cos(this.tick + n.phase) * 0.08;
n.y += n.vy + Math.sin(this.tick * 1.2 + n.phase) * 0.08;
if (n.x < 0 || n.x > this.width) n.vx *= -1;
if (n.y < 0 || n.y > this.height) n.vy *= -1;
n.x = Math.max(0, Math.min(this.width, n.x));
n.y = Math.max(0, Math.min(this.height, n.y));
const pulse = 0.55 + Math.sin(this.tick * 2 + n.phase) * 0.45;
const rr = n.r * (0.9 + pulse * 0.2);
const isHover = i === this.hoverIndex;
const color = clusterColor(n.cluster, n.energy);
this.ctx.beginPath();
this.ctx.arc(n.x, n.y, rr + (isHover ? 1.8 : 0), 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.shadowBlur = isHover ? 14 : 6;
this.ctx.shadowColor = color;
this.ctx.fill();
this.ctx.shadowBlur = 0;
}
}
drawOverlay() {
const m = this.meta;
this.ctx.fillStyle = 'rgba(5,8,12,0.7)';
this.ctx.fillRect(10, 10, 270, 68);
this.ctx.strokeStyle = 'rgba(85,120,145,0.35)';
this.ctx.strokeRect(10, 10, 270, 68);
this.ctx.fillStyle = '#d1ecff';
this.ctx.font = '11px "Fira Code", monospace';
this.ctx.fillText(`Model: ${m.model_version || 'none'}`, 18, 28);
this.ctx.fillText(`Params: ${fmtInt(m.model_param_count)} | Layers: ${m.model_layer_count || 0}`, 18, 46);
this.ctx.fillText(`Features: ${m.model_feature_count || 0} | Nodes: ${this.nodes.length}`, 18, 64);
}
}
function fmtInt(v) {
try {
return Number(v || 0).toLocaleString();
} catch {
return String(v || 0);
}
}
function clusterColor(cluster, energy) {
const palette = [
[0, 220, 255],
[0, 255, 160],
[180, 140, 255],
[255, 120, 180],
[255, 200, 90],
];
const base = palette[Math.abs(cluster) % palette.length];
const a = 0.25 + Math.max(0.0, Math.min(1.0, energy)) * 0.7;
return `rgba(${base[0]},${base[1]},${base[2]},${a})`;
}
/* ======================== Layout ======================== */
function buildLayout() {
const mobileStyle = `
@media (max-width: 768px) {
.brain-hero { height: 220px !important; margin-bottom: 12px !important; border-radius: 14px !important; }
.kpi-cards { grid-template-columns: 1fr 1fr !important; gap: 8px !important; }
.grid-stack { grid-template-columns: 1fr !important; gap: 12px !important; }
.title { font-size: 1.25rem !important; }
}
`;
return el('div', { class: 'dashboard-container' }, [
el('style', {}, [mobileStyle]),
el('div', { class: 'head', style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px' }, [
el('h2', { class: 'title' }, ['AI Brain Cloud']),
]),
el('div', {
class: 'brain-hero',
style: 'position:relative; width:min(860px,96%); height:360px; margin:0 auto 20px; border-radius:18px; background:#030507; border:1px solid #233036; overflow:hidden; box-shadow: 0 0 28px rgba(0,170,255,0.16)',
}, [
el('canvas', { id: 'brain-canvas', style: 'width:100%;height:100%' }),
el('div', { id: 'brain-tooltip', style: 'position:absolute; top:0; left:0; background:rgba(0,0,0,0.85); border:1px solid var(--acid); color:#fff; padding:8px 12px; border-radius:4px; font-size:0.8em; pointer-events:none; display:none; z-index:10; white-space:nowrap;' }),
]),
el('div', { class: 'kpi-cards', style: 'display:flex; gap:10px; margin-bottom:20px; overflow-x:auto; padding-bottom:5px' }, [
el('div', { class: 'kpi', style: 'flex:0 0 250px; display:flex; flex-direction:column; justify-content:center' }, [
el('div', { class: 'label', style: 'margin-bottom:5px' }, ['Operation Mode']),
el('div', { class: 'mode-selector', style: 'display:flex; gap:2px; background:#111; padding:2px; border-radius:4px; border:1px solid #333' }, [
el('button', { class: 'mode-btn', id: 'mode-manual', onclick: () => setOperationMode('MANUAL'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['MANUAL']),
el('button', { class: 'mode-btn', id: 'mode-auto', onclick: () => setOperationMode('AUTO'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AUTO']),
el('button', { class: 'mode-btn', id: 'mode-ai', onclick: () => setOperationMode('AI'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AI']),
]),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Episodes']),
el('div', { class: 'val', id: 'val-episodes', style: 'font-size:1.5em' }, ['0']),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Epsilon']),
el('div', { class: 'val', id: 'val-epsilon', style: 'font-size:1.5em; color:cyan' }, ['0.00']),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Q-Size']),
el('div', { class: 'val', id: 'val-qsize', style: 'font-size:1.5em' }, ['0']),
]),
el('div', { id: 'mini-graph-container', style: 'flex:2; border-left:1px solid #333; padding-left:15px; position:relative; min-width:300px' }, [
el('canvas', { id: 'metrics-canvas', style: 'width:100%; height:100%' }),
]),
]),
el('div', { class: 'grid-stack', style: 'display:grid;grid-template-columns:1fr 1fr; gap:20px;' }, [
el('div', { class: 'card' }, [
el('h3', {}, ['Model Manifest']),
el('div', { id: 'model-manifest', style: 'display:flex; flex-wrap:wrap; gap:5px; margin-top:10px; max-height:250px; overflow-y:auto' }),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Recent Confidence Signals']),
el('div', { id: 'confidence-bars', style: 'margin-top:10px; display:flex; flex-direction:column; gap:8px' }),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Data Sync History']),
el('div', { class: 'table-responsive', style: 'max-height:400px;overflow-y:auto' }, [
el('table', { class: 'table' }, [
el('thead', {}, [el('tr', {}, [el('th', {}, ['Time']), el('th', {}, ['Records']), el('th', {}, ['Sync Status'])])]),
el('tbody', { id: 'history-body' }),
]),
]),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Recent Experiences']),
el('div', { id: 'experience-feed', style: 'display:flex;flex-direction:column;gap:10px;max-height:400px;overflow-y:auto' }),
]),
]),
]);
}
/* ======================== Fetchers ======================== */
async function fetchStats() {
try {
const data = await api.get('/api/rl/stats');
if (!data) return;
if (!metricsGraph && document.getElementById('metrics-canvas')) {
metricsGraph = new MultiMetricGraph('metrics-canvas');
if (tracker) tracker.trackResource(() => metricsGraph && metricsGraph.destroy());
}
if (metricsGraph) metricsGraph.update(data);
if (!modelCloud && document.getElementById('brain-canvas')) {
modelCloud = new ModelCloud('brain-canvas');
if (tracker) tracker.trackResource(() => modelCloud && modelCloud.destroy());
}
if (modelCloud) modelCloud.updateFromStats(data);
setText($('#val-episodes'), data.episodes ?? 0);
setText($('#val-epsilon'), Number(data.epsilon || 0).toFixed(4));
setText($('#val-qsize'), data.q_table_size ?? 0);
updateModeUI(data.mode || (data.ai_mode ? 'AI' : data.manual_mode ? 'MANUAL' : 'AUTO'));
updateManifest(data);
if (Array.isArray(data.recent_activity) && data.recent_activity.length) {
renderConfidenceBars(data.recent_activity);
}
} catch (e) {
console.error(e);
}
}
function updateManifest(data) {
const manifest = $('#model-manifest');
if (!manifest) return;
empty(manifest);
const tags = [
`MODEL: ${data.model_loaded ? 'LOADED' : 'HEURISTIC'}`,
`VERSION: ${data.model_version || 'N/A'}`,
`PARAMS: ${fmtInt(data.model_param_count || 0)}`,
`LAYERS: ${data.model_layer_count || 0}`,
`FEATURES: ${data.model_feature_count || 0}`,
`SAMPLES: ${fmtInt(data.training_samples || 0)}`,
];
tags.forEach((txt) => {
manifest.appendChild(el('div', {
style: 'background:#111; border:1px solid #333; padding:3px 8px; border-radius:4px; font-size:0.72em; color:var(--text-main); white-space:nowrap',
}, [txt]));
});
}
function renderConfidenceBars(activity) {
const container = $('#confidence-bars');
if (!container) return;
empty(container);
activity.forEach((act) => {
const reward = Number(act.reward || 0);
const color = reward > 0 ? 'var(--acid)' : '#ff3333';
const success = reward > 0;
container.appendChild(el('div', { style: 'display:flex; flex-direction:column; gap:2px' }, [
el('div', { style: 'display:flex; justify-content:space-between; font-size:0.8em' }, [
el('span', {}, [act.action || '-']),
el('span', { style: `color:${color}` }, [success ? 'CONFIDENT' : 'UNCERTAIN']),
]),
el('div', { style: 'height:4px; background:#222; border-radius:3px; overflow:hidden' }, [
el('div', { style: `height:100%; background:${color}; width:${Math.min(Math.abs(reward) * 5, 100)}%; transition:width 0.45s ease-out` }),
]),
]));
});
}
async function fetchHistory() {
try {
const data = await api.get('/api/rl/history');
if (!data || !Array.isArray(data.history)) return;
const tbody = $('#history-body');
empty(tbody);
data.history.forEach((row) => {
const ts = String(row.timestamp || '');
const parsed = new Date(ts.includes('Z') ? ts : `${ts}Z`);
tbody.appendChild(el('tr', {}, [
el('td', {}, [Number.isFinite(parsed.getTime()) ? parsed.toLocaleTimeString() : ts]),
el('td', {}, [String(row.record_count || 0)]),
el('td', { style: 'color:var(--acid)' }, ['COMPLETED']),
]));
});
} catch (e) {
console.error(e);
}
}
async function fetchExperiences() {
try {
const data = await api.get('/api/rl/experiences');
if (!data || !Array.isArray(data.experiences)) return;
const container = $('#experience-feed');
empty(container);
data.experiences.forEach((exp) => {
let color = 'var(--text-main)';
if (exp.reward > 0) color = 'var(--acid)';
if (exp.reward < 0) color = 'var(--glitch)';
container.appendChild(el('div', {
class: 'exp-item',
style: `padding:8px; background:rgba(255,255,255,0.05); border-radius:4px; border-left:3px solid ${color}`,
}, [
el('div', { style: 'display:flex;justify-content:space-between' }, [
el('strong', {}, [exp.action_name || '-']),
el('span', { style: `color:${color};font-weight:bold` }, [exp.reward > 0 ? `+${exp.reward}` : `${exp.reward}`]),
]),
el('div', { style: 'font-size:0.85em; opacity:0.7; margin-top:4px' }, [
el('span', {}, [new Date(String(exp.timestamp || '').includes('Z') ? exp.timestamp : `${exp.timestamp}Z`).toLocaleString()]),
' - ',
el('span', {}, [exp.success ? 'SUCCESS' : 'FAIL']),
]),
]));
});
} catch (e) {
console.error(e);
}
}
function updateModeUI(mode) {
if (!mode) return;
const m = String(mode).toUpperCase().trim();
['MANUAL', 'AUTO', 'AI'].forEach((v) => {
const btn = $(`#mode-${v.toLowerCase()}`);
if (!btn) return;
if (v === m) {
btn.style.background = 'var(--acid)';
btn.style.color = '#000';
btn.style.fontWeight = 'bold';
} else {
btn.style.background = 'none';
btn.style.color = '#666';
btn.style.fontWeight = 'normal';
}
});
}
async function setOperationMode(mode) {
try {
const data = await api.post('/api/rl/config', { mode });
if (data.status === 'ok') {
updateModeUI(data.mode);
if (window.toast) window.toast(`Operation Mode: ${data.mode}`);
const bc = new BroadcastChannel('bjorn_mode_sync');
bc.postMessage({ mode: data.mode });
bc.close();
} else if (window.toast) {
window.toast(`Error: ${data.message}`, 'error');
}
} catch (err) {
console.error(err);
if (window.toast) window.toast('Communication Error', 'error');
}
}

544
web/js/pages/scheduler.js Normal file
View File

@@ -0,0 +1,544 @@
/**
* Scheduler page module.
* Kanban-style board with 6 lanes, live refresh, countdown timers, search, history modal.
* Endpoints: GET /action_queue, POST /queue_cmd, GET /attempt_history
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'scheduler';
const PAGE_SIZE = 100;
const LANES = ['running', 'pending', 'upcoming', 'success', 'failed', 'cancelled'];
const LANE_LABELS = {
running: t('sched.running'),
pending: t('sched.pending'),
upcoming: t('sched.upcoming'),
success: t('sched.success'),
failed: t('sched.failed'),
cancelled: t('sched.cancelled')
};
/* ── state ── */
let tracker = null;
let poller = null;
let clockTimer = null;
let LIVE = true;
let FOCUS = false;
let COMPACT = false;
let COLLAPSED = false;
let INCLUDE_SUPERSEDED = false;
let lastBuckets = null;
let showCount = null;
let lastFilterKey = '';
let iconCache = new Map();
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
container.appendChild(buildShell());
tracker.trackEventListener(window, 'keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
await tick();
setLive(true);
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
lastBuckets = null;
showCount = null;
iconCache.clear();
}
/* ── shell ── */
function buildShell() {
return el('div', { class: 'scheduler-container' }, [
el('div', { id: 'sched-errorBar', class: 'notice', style: 'display:none' }),
el('div', { class: 'controls' }, [
el('input', {
type: 'text', id: 'sched-search', placeholder: 'Filter (action, MAC, IP, host, service, port...)',
oninput: onSearch
}),
pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)),
pill('sched-refBtn', t('common.refresh'), false, () => tick()),
pill('sched-focBtn', 'Focus active', false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }),
pill('sched-cmpBtn', 'Compact', false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }),
pill('sched-colBtn', 'Collapse', false, toggleCollapse),
pill('sched-supBtn', INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded', false, toggleSuperseded),
el('span', { id: 'sched-stats', class: 'stats' }),
]),
el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [
el('div', { id: 'sched-board', class: 'board' }),
]),
/* history modal */
el('div', {
id: 'sched-histModal', class: 'modalOverlay', style: 'display:none', 'aria-hidden': 'true',
onclick: (e) => { if (e.target.id === 'sched-histModal') closeModal(); }
}, [
el('div', { class: 'modal' }, [
el('div', { class: 'modalHeader' }, [
el('div', { class: 'title' }, [t('sched.history')]),
el('div', { id: 'sched-histTitle', class: 'muted' }),
el('div', { class: 'spacer' }),
el('button', { class: 'xBtn', onclick: closeModal }, [t('common.close')]),
]),
el('div', { id: 'sched-histBody', class: 'modalBody' }),
el('div', { class: 'modalFooter' }, [
el('small', {}, ['Rows are color-coded by status.']),
]),
]),
]),
]);
}
function pill(id, text, active, onclick) {
return el('span', { id, class: `pill ${active ? 'active' : ''}`, onclick }, [text]);
}
/* ── data fetch ── */
async function fetchQueue() {
const data = await api.get('/action_queue', { timeout: 8000 });
const rawRows = Array.isArray(data) ? data : (data?.rows || []);
return rawRows.map(normalizeRow);
}
function normalizeRow(r) {
const status = (r.status || '').toLowerCase() === 'expired' ? 'failed' : (r.status || '').toLowerCase();
const scheduled_ms = isoToMs(r.scheduled_for);
const created_ms = isoToMs(r.created_at) || Date.now();
const started_ms = isoToMs(r.started_at);
const completed_ms = isoToMs(r.completed_at);
let _computed_status = status;
if (status === 'scheduled') _computed_status = 'upcoming';
else if (status === 'pending' && scheduled_ms > Date.now()) _computed_status = 'upcoming';
const tags = dedupeArr(toArray(r.tags));
const metadata = typeof r.metadata === 'string' ? parseJSON(r.metadata, {}) : (r.metadata || {});
return {
...r, status, scheduled_ms, created_ms, started_ms, completed_ms,
_computed_status, tags, metadata,
mac: r.mac || r.mac_address || '',
priority_effective: r.priority_effective ?? r.priority ?? 0,
};
}
/* ── tick / render ── */
async function tick() {
try {
const rows = await fetchQueue();
render(rows);
} catch (e) {
showError('Queue fetch error: ' + e.message);
}
}
function render(rows) {
const q = ($('#sched-search')?.value || '').toLowerCase();
/* filter */
let filtered = rows;
if (q) {
filtered = filtered.filter(r => {
const bag = `${r.action_name} ${r.mac} ${r.ip} ${r.hostname} ${r.service} ${r.port} ${(r.tags || []).join(' ')}`.toLowerCase();
return bag.includes(q);
});
}
if (FOCUS) filtered = filtered.filter(r => ['upcoming', 'pending', 'running'].includes(r._computed_status));
/* superseded filter */
if (!INCLUDE_SUPERSEDED) {
const activeKeys = new Set();
filtered.forEach(r => {
if (['upcoming', 'pending', 'running'].includes(r._computed_status)) {
activeKeys.add(`${r.action_name}|${r.mac}|${r.port || 0}`);
}
});
filtered = filtered.filter(r => {
if (r._computed_status !== 'failed') return true;
const key = `${r.action_name}|${r.mac}|${r.port || 0}`;
return !activeKeys.has(key);
});
}
/* dedupe failed: keep highest retry per key */
const failMap = new Map();
filtered.filter(r => r._computed_status === 'failed').forEach(r => {
const key = `${r.action_name}|${r.mac}|${r.port || 0}`;
const prev = failMap.get(key);
if (!prev || (r.retry_count || 0) > (prev.retry_count || 0) || r.created_ms > prev.created_ms) failMap.set(key, r);
});
const failIds = new Set(Array.from(failMap.values()).map(r => r.id));
filtered = filtered.filter(r => r._computed_status !== 'failed' || failIds.has(r.id));
/* bucket */
const buckets = {};
LANES.forEach(l => buckets[l] = []);
filtered.forEach(r => {
const lane = buckets[r._computed_status];
if (lane) lane.push(r);
});
/* sort per lane */
const byNewest = (a, b) => Math.max(b.completed_ms, b.started_ms, b.created_ms) - Math.max(a.completed_ms, a.started_ms, a.created_ms);
const byPrio = (a, b) => (b.priority_effective - a.priority_effective) || byNewest(a, b);
buckets.running.sort(byPrio);
buckets.pending.sort((a, b) => byPrio(a, b) || (a.scheduled_ms || a.created_ms) - (b.scheduled_ms || b.created_ms));
buckets.upcoming.sort((a, b) => (a.scheduled_ms || Infinity) - (b.scheduled_ms || Infinity));
buckets.success.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms));
buckets.failed.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms));
buckets.cancelled.sort(byPrio);
if (COMPACT) {
LANES.forEach(l => {
buckets[l] = keepLatest(buckets[l], r => `${r.action_name}|${r.mac}|${r.port || 0}`, r => Math.max(r.completed_ms, r.started_ms, r.created_ms));
});
}
/* stats */
const total = filtered.length;
const statsEl = $('#sched-stats');
if (statsEl) statsEl.textContent = `${total} entries | R:${buckets.running.length} P:${buckets.pending.length} U:${buckets.upcoming.length} S:${buckets.success.length} F:${buckets.failed.length}`;
/* pagination */
const fk = filterKey(q);
if (fk !== lastFilterKey) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); lastFilterKey = fk; }
if (!showCount) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); }
lastBuckets = buckets;
renderBoard(buckets);
}
function renderBoard(buckets) {
const board = $('#sched-board');
if (!board) return;
empty(board);
LANES.forEach(lane => {
const items = buckets[lane] || [];
const visible = items.slice(0, showCount?.[lane] || PAGE_SIZE);
const hasMore = items.length > visible.length;
const laneEl = el('div', { class: `lane status-${lane}` }, [
el('div', { class: 'laneHeader' }, [
el('span', { class: 'dot' }),
el('strong', {}, [LANE_LABELS[lane]]),
el('span', { class: 'count' }, [String(items.length)]),
]),
el('div', { class: 'laneBody' },
visible.length === 0
? [el('div', { class: 'empty' }, ['No entries'])]
: [
...visible.map(r => cardEl(r)),
...(hasMore ? [el('button', {
class: 'moreBtn', onclick: () => {
showCount[lane] = (showCount[lane] || PAGE_SIZE) + PAGE_SIZE;
if (lastBuckets) renderBoard(lastBuckets);
}
}, ['Display more\u2026'])] : []),
]
),
]);
board.appendChild(laneEl);
});
if (COLLAPSED) $$('.card', board).forEach(c => c.classList.add('collapsed'));
/* restart countdown clock */
if (clockTimer) clearInterval(clockTimer);
clockTimer = setInterval(updateCountdowns, 1000);
}
/* ── card ── */
function cardEl(r) {
const cs = r._computed_status;
const children = [];
/* info button */
children.push(el('button', {
class: 'infoBtn', title: t('sched.history'),
onclick: () => openHistory(r.action_name, r.mac, r.port || 0)
}, ['i']));
/* header */
children.push(el('div', { class: 'cardHeader' }, [
el('div', { class: 'actionIconWrap' }, [
el('img', {
class: 'actionIcon', src: resolveIconSync(r.action_name),
width: '80', height: '80', onerror: (e) => { e.target.src = '/actions/actions_icons/default.png'; }
}),
]),
el('div', { class: 'actionName' }, [
el('span', { class: 'chip', style: `--h:${hashHue(r.action_name)}` }, [r.action_name]),
]),
el('span', { class: `badge status-${cs}` }, [cs]),
]));
/* chips */
const chips = [];
if (r.hostname) chips.push(chipEl(r.hostname, 195));
if (r.ip) chips.push(chipEl(r.ip, 195));
if (r.port) chips.push(chipEl(`Port ${r.port}`, 210, 'Port'));
if (r.mac) chips.push(chipEl(r.mac, 195));
if (chips.length) children.push(el('div', { class: 'chips' }, chips));
/* service kv */
if (r.service) children.push(el('div', { class: 'kv' }, [el('span', {}, [`Svc: ${r.service}`])]));
/* tags */
if (r.tags?.length) {
children.push(el('div', { class: 'tags' },
r.tags.map(tag => el('span', { class: 'tag' }, [tag]))));
}
/* timer */
if ((cs === 'upcoming' || (cs === 'pending' && r.scheduled_ms > Date.now())) && r.scheduled_ms) {
children.push(el('div', { class: 'timer', 'data-type': 'start', 'data-ts': String(r.scheduled_ms) }, [
'Eligible in ', el('span', { class: 'cd' }, ['-']),
]));
children.push(el('div', { class: 'progress' }, [
el('div', { class: 'bar', 'data-start': String(r.created_ms), 'data-end': String(r.scheduled_ms), style: 'width:0%' }),
]));
} else if (cs === 'running' && r.started_ms) {
children.push(el('div', { class: 'timer', 'data-type': 'elapsed', 'data-ts': String(r.started_ms) }, [
'Elapsed ', el('span', { class: 'cd' }, ['-']),
]));
}
/* meta */
const meta = [el('span', {}, [`created: ${fmt(r.created_at)}`])];
if (r.started_at) meta.push(el('span', {}, [`started: ${fmt(r.started_at)}`]));
if (r.completed_at) meta.push(el('span', {}, [`done: ${fmt(r.completed_at)}`]));
if (r.retry_count > 0) meta.push(el('span', { class: 'chip', style: '--h:30' }, [
`retries ${r.retry_count}${r.max_retries != null ? '/' + r.max_retries : ''}`]));
if (r.priority_effective) meta.push(el('span', {}, [`prio: ${r.priority_effective}`]));
children.push(el('div', { class: 'meta' }, meta));
/* buttons */
const btns = [];
if (['upcoming', 'scheduled', 'pending', 'running'].includes(r.status)) {
btns.push(el('button', { class: 'btn warn', onclick: () => queueCmd(r.id, 'cancel') }, ['Cancel']));
}
if (!['running', 'pending', 'scheduled'].includes(r.status)) {
btns.push(el('button', { class: 'btn danger', onclick: () => queueCmd(r.id, 'delete') }, ['Delete']));
}
if (btns.length) children.push(el('div', { class: 'btns' }, btns));
/* error / result */
if (r.error_message) children.push(el('div', { class: 'notice error' }, [r.error_message]));
if (r.result_summary) children.push(el('div', { class: 'notice success' }, [r.result_summary]));
return el('div', { class: `card status-${cs}` }, children);
}
function chipEl(text, hue, prefix) {
const parts = [];
if (prefix) parts.push(el('span', { class: 'k' }, [prefix]), '\u00A0');
parts.push(text);
return el('span', { class: 'chip', style: `--h:${hue}` }, parts);
}
/* ── countdown / progress ── */
function updateCountdowns() {
const now = Date.now();
$$('.timer').forEach(timer => {
const type = timer.dataset.type;
const ts = parseInt(timer.dataset.ts);
const cd = timer.querySelector('.cd');
if (!cd || !ts) return;
if (type === 'start') {
const diff = ts - now;
cd.textContent = diff <= 0 ? 'due' : ms2str(diff);
} else if (type === 'elapsed') {
cd.textContent = ms2str(now - ts);
}
});
$$('.progress .bar').forEach(bar => {
const start = parseInt(bar.dataset.start);
const end = parseInt(bar.dataset.end);
if (!start || !end || end <= start) return;
const pct = Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100));
bar.style.width = pct + '%';
});
}
/* ── queue command ── */
async function queueCmd(id, cmd) {
try {
await api.post('/queue_cmd', { id, cmd });
tick();
} catch (e) {
showError('Command failed: ' + e.message);
}
}
/* ── history modal ── */
async function openHistory(action, mac, port) {
const modal = $('#sched-histModal');
const title = $('#sched-histTitle');
const body = $('#sched-histBody');
if (!modal || !body) return;
if (title) title.textContent = `\u2014 ${action} \u00B7 ${mac}${port && port !== 0 ? ` \u00B7 port ${port}` : ''}`;
empty(body);
body.appendChild(el('div', { class: 'empty' }, ['Loading\u2026']));
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
try {
const url = `/attempt_history?action=${encodeURIComponent(action)}&mac=${encodeURIComponent(mac)}&port=${encodeURIComponent(port)}&limit=100`;
const data = await api.get(url, { timeout: 8000 });
const rows = Array.isArray(data) ? data : (data?.rows || data || []);
empty(body);
if (!rows.length) {
body.appendChild(el('div', { class: 'empty' }, ['No history']));
return;
}
const norm = rows.map(x => ({
status: (x.status || '').toLowerCase(),
retry_count: Number(x.retry_count || 0),
max_retries: x.max_retries,
ts: x.ts || x.completed_at || x.started_at || x.scheduled_for || x.created_at || '',
})).sort((a, b) => (b.ts > a.ts ? 1 : -1));
norm.forEach(hr => {
const st = hr.status || 'unknown';
const retry = (hr.retry_count || hr.max_retries != null)
? el('span', { style: 'color:var(--ink)' }, [`retry ${hr.retry_count}${hr.max_retries != null ? '/' + hr.max_retries : ''}`])
: null;
body.appendChild(el('div', { class: `histRow hist-${st}` }, [
el('span', { class: 'ts' }, [fmt(hr.ts)]),
retry,
el('span', { style: 'margin-left:auto' }),
el('span', { class: 'st' }, [st]),
].filter(Boolean)));
});
} catch (e) {
empty(body);
body.appendChild(el('div', { class: 'empty' }, [`Error: ${e.message}`]));
}
}
function closeModal() {
const modal = $('#sched-histModal');
if (!modal) return;
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
}
/* ── controls ── */
function setLive(on) {
LIVE = on;
const btn = $('#sched-liveBtn');
if (btn) btn.classList.toggle('active', LIVE);
if (poller) { poller.stop(); poller = null; }
if (LIVE) {
poller = new Poller(tick, 2500, { immediate: false });
poller.start();
}
}
function toggleCollapse() {
COLLAPSED = !COLLAPSED;
const btn = $('#sched-colBtn');
if (btn) btn.textContent = COLLAPSED ? 'Expand' : 'Collapse';
$$('#sched-board .card').forEach(c => c.classList.toggle('collapsed', COLLAPSED));
}
function toggleSuperseded() {
INCLUDE_SUPERSEDED = !INCLUDE_SUPERSEDED;
const btn = $('#sched-supBtn');
if (btn) {
btn.classList.toggle('active', INCLUDE_SUPERSEDED);
btn.textContent = INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded';
}
lastFilterKey = '';
tick();
}
let searchDeb = null;
function onSearch() {
clearTimeout(searchDeb);
searchDeb = setTimeout(() => { lastFilterKey = ''; tick(); }, 180);
}
function showError(msg) {
const bar = $('#sched-errorBar');
if (!bar) return;
bar.textContent = msg;
bar.style.display = 'block';
setTimeout(() => { bar.style.display = 'none'; }, 5000);
}
/* ── icon resolution ── */
function resolveIconSync(name) {
if (iconCache.has(name)) return iconCache.get(name);
/* async resolve, return default for now */
resolveIconAsync(name);
return '/actions/actions_icons/default.png';
}
async function resolveIconAsync(name) {
if (iconCache.has(name)) return;
const candidates = [
`/actions/actions_icons/${name}.png`,
`/resources/images/status/${name}/${name}.bmp`,
];
for (const url of candidates) {
try {
const r = await fetch(url, { method: 'HEAD', cache: 'force-cache' });
if (r.ok) { iconCache.set(name, url); updateIconsInDOM(name, url); return; }
} catch { /* next */ }
}
iconCache.set(name, '/actions/actions_icons/default.png');
}
function updateIconsInDOM(name, url) {
$$(`img.actionIcon`).forEach(img => {
if (img.closest('.cardHeader')?.querySelector('.actionName')?.textContent?.trim() === name) {
if (img.src !== url) img.src = url;
}
});
}
/* ── helpers ── */
function isoToMs(ts) { if (!ts) return 0; return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).getTime() || 0; }
function fmt(ts) { if (!ts) return '-'; try { return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).toLocaleString(); } catch { return ts; } }
function ms2str(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`;
if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`;
return `${sec}s`;
}
function toArray(v) {
if (!v) return [];
if (Array.isArray(v)) return v.map(String).filter(Boolean);
try { const p = JSON.parse(v); if (Array.isArray(p)) return p.map(String).filter(Boolean); } catch { /* noop */ }
return String(v).split(',').map(s => s.trim()).filter(Boolean);
}
function dedupeArr(a) { return [...new Set(a)]; }
function parseJSON(s, fb) { try { return JSON.parse(s); } catch { return fb; } }
function hashHue(str) { let h = 0; for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0; return ((h % 360) + 360) % 360; }
function filterKey(q) { return `${q}|${FOCUS}|${COMPACT}|${INCLUDE_SUPERSEDED}`; }
function keepLatest(rows, keyFn, dateFn) {
const map = new Map();
rows.forEach(r => {
const k = keyFn(r);
const prev = map.get(k);
if (!prev || dateFn(r) > dateFn(prev)) map.set(k, r);
});
return Array.from(map.values());
}

View File

@@ -0,0 +1,917 @@
/**
* Vulnerabilities page module — Bjorn Project
*
* Changes vs previous version:
* - Card click → opens detail modal directly (no manual expand needed)
* - Direct chips on every card: 🐱 GitHub PoC · 🛡 Rapid7 · NVD ↗ · MITRE ↗
* - Global "💣 Search All Exploits" button: batch enrichment, stored in DB
* - Exploit chips rendered from DB data, updated after enrichment
* - Progress indicator during global exploit search
* - Poller suspended while modal is open
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'vulnerabilities';
const ITEMS_PER_PAGE = 20;
const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 };
/* ── state ── */
let tracker = null;
let poller = null;
let disposeSidebarLayout = null;
let vulnerabilities = [];
let filteredVulns = [];
let currentView = 'cve';
let showActiveOnly = false;
let severityFilters = new Set();
let searchTerm = '';
let currentPage = 1;
let totalPages = 1;
let expandedHosts = new Set();
let historyMode = false;
let sortField = 'cvss_score';
let sortDir = 'desc';
let dateFrom = '';
let dateTo = '';
let lastFetchTime = null;
let modalInFlight = null;
let searchDebounce = null;
let historyPage = 1;
let historySearch = '';
let allHistory = [];
let exploitSearchRunning = false;
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
/* ════════════════════════════════════════
LIFECYCLE
═══════════════════════════════════════ */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const shell = buildShell();
container.appendChild(shell);
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.vuln-sidebar',
mainSelector: '.vuln-main',
storageKey: 'sidebar:vulnerabilities',
toggleLabel: t('common.menu'),
});
await fetchVulnerabilities();
loadFeedStatus();
const interval = parseInt(getPref('vuln:refresh', '30000'), 10) || 30000;
if (interval > 0) {
poller = new Poller(fetchVulnerabilities, interval);
poller.start();
}
}
export function unmount() {
clearTimeout(searchDebounce);
searchDebounce = null;
if (poller) { poller.stop(); poller = null; }
if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch {} disposeSidebarLayout = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
vulnerabilities = []; filteredVulns = [];
currentView = 'cve'; showActiveOnly = false;
severityFilters.clear(); searchTerm = '';
currentPage = 1; expandedHosts.clear();
historyMode = false; modalInFlight = null; allHistory = [];
}
/* ════════════════════════════════════════
SHELL
═══════════════════════════════════════ */
function buildShell() {
const sidebar = el('aside', { class: 'vuln-sidebar page-sidebar panel' }, [
el('div', { class: 'sidehead' }, [
el('div', { class: 'sidetitle' }, [t('nav.vulnerabilities')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]),
]),
el('div', { class: 'sidecontent' }, [
/* stats */
el('div', { class: 'stats-header' }, [
statItem('\u{1F6E1}', 'total-cves', 'Total CVEs'),
statItem('\u{1F534}', 'active-vulns', 'Active'),
statItem('\u2705', 'remediated-vulns', 'Remediated'),
statItem('\u{1F525}', 'critical-count', 'Critical'),
statItem('\u{1F5A5}', 'affected-hosts', 'Hosts'),
statItem('\u{1F4A3}', 'exploit-count', 'w/ Exploit'),
statItem('\u26A0', 'kev-count', 'KEV'),
]),
/* freshness */
el('div', { id: 'vuln-freshness', style: 'font-size:.75rem;opacity:.5;padding:8px 0 0 4px' }),
/* ── feed sync ── */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('button', {
id: 'btn-feed-sync',
class: 'vuln-btn exploit-btn',
style: 'width:100%;font-weight:600',
onclick: runFeedSync,
}, ['\u{1F504} Update Exploit Feeds']),
el('div', { id: 'feed-sync-status', style: 'font-size:.72rem;opacity:.55;margin-top:4px;min-height:16px' }),
]),
/* sort */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Sort by']),
el('select', { id: 'vuln-sort-field', class: 'vuln-select', onchange: onSortChange }, [
el('option', { value: 'cvss_score' }, ['CVSS Score']),
el('option', { value: 'severity' }, ['Severity']),
el('option', { value: 'last_seen' }, ['Last Seen']),
el('option', { value: 'first_seen' }, ['First Seen']),
]),
el('select', { id: 'vuln-sort-dir', class: 'vuln-select', onchange: onSortChange, style: 'margin-top:4px' }, [
el('option', { value: 'desc' }, ['Descending']),
el('option', { value: 'asc' }, ['Ascending']),
]),
]),
/* date filter */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Date filter (last seen)']),
el('input', { type: 'date', id: 'vuln-date-from', class: 'vuln-date-input', onchange: onDateChange }),
el('input', { type: 'date', id: 'vuln-date-to', class: 'vuln-date-input', onchange: onDateChange, style: 'margin-top:4px' }),
el('button', { class: 'vuln-btn', style: 'margin-top:6px;width:100%', onclick: clearDateFilter }, ['Clear dates']),
]),
]),
]);
const main = el('div', { class: 'vuln-main page-main' }, [
el('div', { class: 'vuln-controls' }, [
el('div', { class: 'global-search-container' }, [
el('input', { type: 'text', class: 'global-search-input', id: 'vuln-search', placeholder: t('common.search'), oninput: onSearch }),
el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']),
]),
el('div', { class: 'vuln-buttons' }, [
el('button', { class: 'vuln-btn active', id: 'vuln-view-cve', onclick: () => switchView('cve') }, ['CVE View']),
el('button', { class: 'vuln-btn', id: 'vuln-view-host', onclick: () => switchView('host') }, ['Host View']),
el('button', { class: 'vuln-btn', id: 'vuln-view-exploits', onclick: () => switchView('exploits') }, ['\u{1F4A3} Exploits']),
el('button', { class: 'vuln-btn', id: 'vuln-active-toggle', onclick: toggleActiveFilter }, [t('status.online')]),
el('button', { class: 'vuln-btn', id: 'vuln-history-btn', onclick: toggleHistory }, [t('sched.history')]),
el('button', { class: 'vuln-btn', onclick: exportCSV }, [t('common.export') + ' CSV']),
el('button', { class: 'vuln-btn', onclick: exportJSON }, [t('common.export') + ' JSON']),
]),
]),
el('div', { class: 'vuln-severity-bar' }, [
severityBtn('critical'), severityBtn('high'), severityBtn('medium'), severityBtn('low'),
]),
el('div', { class: 'services-grid', id: 'vuln-grid' }),
el('div', { class: 'vuln-pagination', id: 'vuln-pagination' }),
/* ── MODAL ── */
el('div', { class: 'vuln-modal', id: 'vuln-modal', onclick: onModalBackdrop }, [
el('div', { class: 'vuln-modal-content' }, [
el('div', { class: 'vuln-modal-header' }, [
el('span', { class: 'vuln-modal-title', id: 'vuln-modal-title' }),
/* ref chips in modal header */
el('div', { class: 'vuln-modal-header-chips', id: 'vuln-modal-header-chips' }),
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
]),
el('div', { class: 'vuln-modal-body', id: 'vuln-modal-body' }),
]),
]),
]);
return el('div', { class: 'vuln-container page-with-sidebar' }, [sidebar, main]);
}
function statItem(icon, id, label) {
return el('div', { class: 'stat-card stat-item' }, [
el('span', { class: 'stat-icon' }, [icon]),
el('span', { class: 'stat-number stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function severityBtn(sev) {
return el('button', {
class: `vuln-severity-btn severity-${sev}`,
'data-severity': sev,
onclick: (e) => toggleSeverity(sev, e.currentTarget),
}, [sev.charAt(0).toUpperCase() + sev.slice(1)]);
}
/* ════════════════════════════════════════
DATA FETCH
═══════════════════════════════════════ */
async function fetchVulnerabilities() {
if (historyMode) return;
try {
const data = await api.get('/list_vulnerabilities', { timeout: 10000 });
vulnerabilities = Array.isArray(data) ? data : (data?.vulnerabilities || []);
lastFetchTime = new Date();
const f = $('#vuln-freshness');
if (f) f.textContent = `Last refresh: ${lastFetchTime.toLocaleTimeString()}`;
updateStats();
filterAndRender();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
/* ════════════════════════════════════════
FEED SYNC
POST /api/feeds/sync — downloads CISA KEV + Exploit-DB + EPSS into local DB
GET /api/feeds/status — last sync timestamps
═══════════════════════════════════════ */
async function runFeedSync() {
const btn = $('#btn-feed-sync');
const status = $('#feed-sync-status');
if (btn && btn.disabled) return;
if (btn) { btn.disabled = true; btn.textContent = '\u23F3 Downloading\u2026'; }
if (status) status.textContent = 'Syncing CISA KEV, Exploit-DB, EPSS\u2026';
try {
const res = await api.post('/api/feeds/sync', {}, { timeout: 120000 });
const feeds = res?.feeds || {};
const parts = [];
for (const [name, info] of Object.entries(feeds)) {
if (info.status === 'ok') parts.push(`${name}: ${info.count} records`);
else parts.push(`${name}: \u274C ${info.message || 'error'}`);
}
if (status) status.textContent = '\u2705 ' + (parts.join(' \u00B7 ') || 'Done');
await fetchVulnerabilities();
} catch (err) {
if (status) status.textContent = `\u274C ${err.message}`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '\u{1F504} Update Exploit Feeds'; }
}
}
async function loadFeedStatus() {
try {
const res = await api.get('/api/feeds/status');
const status = $('#feed-sync-status');
if (!status || !res?.feeds) return;
const entries = Object.entries(res.feeds);
if (!entries.length) { status.textContent = 'No sync yet — click to update.'; return; }
// show the most recent sync time
const latest = entries.reduce((a, [, v]) => Math.max(a, v.last_synced || 0), 0);
if (latest) {
const d = new Date(latest * 1000);
status.textContent = `Last sync: ${d.toLocaleDateString()} ${d.toLocaleTimeString()} \u00B7 ${res.total_exploits || 0} exploits`;
}
} catch { /* ignore */ }
}
/* ════════════════════════════════════════
STATS
═══════════════════════════════════════ */
function updateStats() {
const sv = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; };
sv('total-cves', vulnerabilities.length);
sv('active-vulns', vulnerabilities.filter(v => v.is_active === 1).length);
sv('remediated-vulns', vulnerabilities.filter(v => v.is_active === 0).length);
sv('critical-count', vulnerabilities.filter(v => v.is_active === 1 && v.severity === 'critical').length);
sv('exploit-count', vulnerabilities.filter(v => v.has_exploit).length);
sv('kev-count', vulnerabilities.filter(v => v.is_kev).length);
const macs = new Set(vulnerabilities.map(v => v.mac_address).filter(Boolean));
sv('affected-hosts', macs.size);
}
/* ════════════════════════════════════════
FILTER + SORT
═══════════════════════════════════════ */
function filterAndRender() {
const needle = searchTerm.toLowerCase();
const from = dateFrom ? new Date(dateFrom).getTime() : null;
const to = dateTo ? new Date(dateTo + 'T23:59:59').getTime() : null;
filteredVulns = vulnerabilities.filter(v => {
if (showActiveOnly && v.is_active === 0) return false;
if (severityFilters.size > 0 && !severityFilters.has(v.severity)) return false;
if (needle) {
if (!`${v.vuln_id} ${v.ip} ${v.hostname} ${v.port} ${v.description}`.toLowerCase().includes(needle)) return false;
}
if (from || to) {
const ls = v.last_seen ? new Date(v.last_seen).getTime() : null;
if (from && (!ls || ls < from)) return false;
if (to && (!ls || ls > to)) return false;
}
return true;
});
filteredVulns.sort((a, b) => {
let va, vb;
switch (sortField) {
case 'severity': va = SEVERITY_ORDER[a.severity] || 0; vb = SEVERITY_ORDER[b.severity] || 0; break;
case 'last_seen': va = a.last_seen ? new Date(a.last_seen).getTime() : 0; vb = b.last_seen ? new Date(b.last_seen).getTime() : 0; break;
case 'first_seen': va = a.first_seen ? new Date(a.first_seen).getTime() : 0; vb = b.first_seen ? new Date(b.first_seen).getTime() : 0; break;
default: va = parseFloat(a.cvss_score) || 0; vb = parseFloat(b.cvss_score) || 0;
}
return sortDir === 'asc' ? va - vb : vb - va;
});
totalPages = Math.max(1, Math.ceil(filteredVulns.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
if (currentView === 'host') renderHostView();
else if (currentView === 'exploits') renderExploitsView();
else renderCVEView();
renderPagination();
}
/* ════════════════════════════════════════
CHIP BUILDERS (shared across all views)
═══════════════════════════════════════ */
/** Four external reference chips — always visible on every card & in modal */
function buildRefChips(cveId) {
const enc = encodeURIComponent(cveId);
return el('div', { class: 'vuln-ref-chips', onclick: e => e.stopPropagation() }, [
refChip('\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'),
refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'),
refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'),
refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'),
]);
}
/** Exploit chips built from DB data — shown only when exploit data exists */
function buildExploitChips(v) {
const exploits = Array.isArray(v.exploits) ? v.exploits : [];
if (!v.has_exploit && exploits.length === 0) return null;
const chips = exploits.slice(0, 5).map(entry => {
const isStr = typeof entry === 'string';
const label = isStr
? (entry.startsWith('http') ? 'ExploitDB' : entry.substring(0, 28))
: (entry.title || 'Exploit').substring(0, 28);
const href = isStr
? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`)
: (entry.url || `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`);
return refChip('\u26A1 ' + label, href, 'chip-exploit');
});
/* fallback generic chip if flag set but no detail yet */
if (chips.length === 0)
chips.push(refChip('\u{1F4A3} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`, 'chip-exploit'));
return el('div', { class: 'vuln-exploit-chips', onclick: e => e.stopPropagation() }, chips);
}
function refChip(label, href, cls) {
return el('a', { href, target: '_blank', rel: 'noopener noreferrer', class: `vuln-chip ${cls}` }, [label]);
}
/* ════════════════════════════════════════
CVE VIEW — full-card click → modal
═══════════════════════════════════════ */
function renderCVEView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const page = filteredVulns.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) { grid.appendChild(emptyState('No vulnerabilities found')); return; }
page.forEach((v, i) => {
const exploitChips = buildExploitChips(v);
const card = el('div', {
class: `vuln-card ${v.is_active === 0 ? 'inactive' : ''}`,
style: `animation-delay:${i * 0.03}s;cursor:pointer`,
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
/* header */
el('div', { class: 'vuln-card-header' }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []),
...(v.is_kev ? [el('span', { class: 'vuln-tag kev', title: 'CISA Known Exploited' }, ['KEV'])] : []),
...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []),
]),
el('span', { style: 'font-size:.72rem;opacity:.35;white-space:nowrap' }, ['\u{1F4CB} click for details']),
]),
/* meta */
el('div', { class: 'vuln-meta' }, [
metaItem('IP', v.ip),
metaItem('Host', v.hostname),
metaItem('Port', v.port),
]),
/* description */
el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px;line-height:1.4' }, [
(v.description || '').substring(0, 160) + ((v.description || '').length > 160 ? '\u2026' : ''),
]),
/* ★ reference chips — always visible */
buildRefChips(v.vuln_id),
/* ★ exploit chips — from DB, only if available */
...(exploitChips ? [exploitChips] : []),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
HOST VIEW
═══════════════════════════════════════ */
function renderHostView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const groups = new Map();
filteredVulns.forEach(v => {
const key = `${v.mac_address}_${v.hostname || 'unknown'}`;
if (!groups.has(key)) groups.set(key, { mac: v.mac_address, hostname: v.hostname, ip: v.ip, vulns: [] });
groups.get(key).vulns.push(v);
});
const hostArr = [...groups.values()];
totalPages = Math.max(1, Math.ceil(hostArr.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
const page = hostArr.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) { grid.appendChild(emptyState('No hosts found')); return; }
page.forEach((host, i) => {
const hostId = `host-${i + (currentPage - 1) * ITEMS_PER_PAGE}`;
const isExpanded = expandedHosts.has(hostId);
const sevCounts = countSeverities(host.vulns);
const remediated = host.vulns.filter(v => v.is_active === 0).length;
const card = el('div', {
class: `vuln-card host-card ${isExpanded ? 'expanded' : ''}`,
'data-id': hostId,
style: `animation-delay:${i * 0.03}s`,
}, [
el('div', { class: 'vuln-card-header', onclick: () => toggleHostCard(hostId) }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-id' }, [host.hostname || host.ip || host.mac || 'Unknown']),
el('span', { class: 'stat-label' }, [`${host.vulns.length} vulns`]),
...(remediated > 0 ? [el('span', { class: 'vuln-tag remediated' }, [`${remediated} FIXED`])] : []),
...(host.vulns.some(v => v.has_exploit) ? [el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}'])] : []),
]),
el('div', { class: 'host-severity-pills' }, [
...(sevCounts.critical > 0 ? [sevPill('critical', sevCounts.critical)] : []),
...(sevCounts.high > 0 ? [sevPill('high', sevCounts.high)] : []),
...(sevCounts.medium > 0 ? [sevPill('medium', sevCounts.medium)] : []),
...(sevCounts.low > 0 ? [sevPill('low', sevCounts.low)] : []),
]),
el('span', { class: 'collapse-indicator' }, ['\u25BC']),
]),
el('div', { class: 'vuln-content' }, [
el('div', { class: 'vuln-meta' }, [
metaItem('IP', host.ip),
metaItem('MAC', host.mac),
metaItem('Active', host.vulns.filter(v => v.is_active === 1).length),
metaItem('Max CVSS', Math.max(...host.vulns.map(v => parseFloat(v.cvss_score) || 0)).toFixed(1)),
]),
...sortVulnsByPriority(host.vulns).map(v => {
const exploitChips = buildExploitChips(v);
return el('div', {
class: `host-vuln-item ${v.is_active === 0 ? 'inactive' : ''}`,
style: 'cursor:pointer',
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
el('div', { class: 'host-vuln-info' }, [
el('span', { class: 'vuln-id' }, [v.vuln_id]),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity]),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []),
]),
el('div', { class: 'vuln-meta', style: 'margin:4px 0' }, [
metaItem('Port', v.port),
metaItem('Last', formatDate(v.last_seen)),
]),
el('div', { style: 'font-size:.82rem;opacity:.65;margin-bottom:6px' }, [
(v.description || '').substring(0, 110) + ((v.description || '').length > 110 ? '\u2026' : ''),
]),
buildRefChips(v.vuln_id),
...(exploitChips ? [exploitChips] : []),
]);
}),
]),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
EXPLOITS VIEW
═══════════════════════════════════════ */
function renderExploitsView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const withExploit = filteredVulns.filter(v => v.has_exploit || (v.exploits && v.exploits.length > 0));
totalPages = Math.max(1, Math.ceil(withExploit.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
const page = withExploit.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) {
const wrapper = el('div', { style: 'text-align:center;padding:40px' }, [
emptyState('\u{1F4A3} No exploit data yet'),
el('div', { style: 'margin-top:16px' }, [
el('button', { class: 'vuln-btn exploit-btn', onclick: runGlobalExploitSearch },
['\u{1F4A3} Search All Exploits now']),
]),
]);
grid.appendChild(wrapper);
return;
}
page.forEach((v, i) => {
const exploitChips = buildExploitChips(v);
const card = el('div', {
class: `vuln-card exploit-card ${v.is_active === 0 ? 'inactive' : ''}`,
style: `animation-delay:${i * 0.03}s;cursor:pointer`,
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
el('div', { class: 'vuln-card-header' }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}']),
el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_kev ? [el('span', { class: 'vuln-tag kev' }, ['KEV'])] : []),
...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []),
]),
el('span', { style: 'font-size:.72rem;opacity:.35' }, ['\u{1F4CB} click for details']),
]),
el('div', { class: 'vuln-meta' }, [metaItem('IP', v.ip), metaItem('Host', v.hostname), metaItem('Port', v.port)]),
el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px' }, [
(v.description || '').substring(0, 180) + ((v.description || '').length > 180 ? '\u2026' : ''),
]),
buildRefChips(v.vuln_id),
...(exploitChips ? [exploitChips] : []),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
HISTORY VIEW
═══════════════════════════════════════ */
async function toggleHistory() {
const btn = $('#vuln-history-btn');
if (historyMode) {
historyMode = false;
if (btn) btn.classList.remove('active');
await fetchVulnerabilities();
return;
}
historyMode = true;
if (btn) btn.classList.add('active');
try {
const data = await api.get('/vulnerabilities/history?limit=500', { timeout: 10000 });
allHistory = data?.history || [];
historyPage = 1; historySearch = '';
renderHistory();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
function renderHistory() {
const grid = $('#vuln-grid'); const pagDiv = $('#vuln-pagination');
if (!grid) return;
empty(grid); if (pagDiv) empty(pagDiv);
const needle = historySearch.toLowerCase();
const filtered = allHistory.filter(e => !needle || `${e.vuln_id} ${e.ip} ${e.hostname}`.toLowerCase().includes(needle));
const hTotal = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
if (historyPage > hTotal) historyPage = 1;
grid.appendChild(el('div', { style: 'margin-bottom:12px' }, [
el('input', {
type: 'text', class: 'global-search-input', value: historySearch,
placeholder: 'Filter history\u2026',
oninput: (e) => { historySearch = e.target.value; historyPage = 1; renderHistory(); },
style: 'width:100%;max-width:360px',
}),
]));
if (!filtered.length) { grid.appendChild(emptyState('No history entries')); return; }
filtered.slice((historyPage - 1) * ITEMS_PER_PAGE, historyPage * ITEMS_PER_PAGE).forEach((entry, i) => {
grid.appendChild(el('div', { class: 'vuln-card', style: `animation-delay:${i * 0.02}s` }, [
el('div', { class: 'vuln-card-header' }, [
el('span', { class: 'vuln-id' }, [entry.vuln_id || 'N/A']),
el('span', { class: 'vuln-tag' }, [entry.event || '']),
]),
el('div', { class: 'vuln-meta' }, [
metaItem('Date', entry.seen_at ? new Date(entry.seen_at).toLocaleString() : 'N/A'),
metaItem('IP', entry.ip), metaItem('Host', entry.hostname),
metaItem('Port', entry.port), metaItem('MAC', entry.mac_address),
]),
]));
});
if (pagDiv && hTotal > 1) {
pagDiv.appendChild(pageBtn('Prev', historyPage > 1, () => { historyPage--; renderHistory(); }));
for (let i = Math.max(1, historyPage - 2); i <= Math.min(hTotal, historyPage + 2); i++) {
pagDiv.appendChild(pageBtn(String(i), true, () => { historyPage = i; renderHistory(); }, i === historyPage));
}
pagDiv.appendChild(pageBtn('Next', historyPage < hTotal, () => { historyPage++; renderHistory(); }));
pagDiv.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${historyPage}/${hTotal}${filtered.length} entries`]));
}
}
/* ════════════════════════════════════════
CVE DETAIL MODAL
═══════════════════════════════════════ */
async function showCVEDetails(cveId) {
if (!cveId || modalInFlight === cveId) return;
modalInFlight = cveId;
if (poller) poller.stop();
const titleEl = $('#vuln-modal-title');
const body = $('#vuln-modal-body');
const modal = $('#vuln-modal');
const chipsEl = $('#vuln-modal-header-chips');
if (!modal) { modalInFlight = null; return; }
if (titleEl) titleEl.textContent = cveId;
/* reference chips in modal header */
if (chipsEl) {
empty(chipsEl);
const enc = encodeURIComponent(cveId);
[
['\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'],
['\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'],
['NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'],
['MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'],
].forEach(([label, href, cls]) => chipsEl.appendChild(refChip(label, href, cls)));
}
if (body) { empty(body); body.appendChild(el('div', { class: 'page-loading' }, ['Loading\u2026'])); }
modal.classList.add('show');
try {
const data = await api.get(`/api/cve/${encodeURIComponent(cveId)}`, { timeout: 10000 });
if (!body) return;
empty(body);
if (data.description) body.appendChild(modalSection('Description', data.description));
if (data.cvss) {
const s = data.cvss;
body.appendChild(modalSection('CVSS',
`Score: ${s.baseScore || 'N/A'} | Severity: ${s.baseSeverity || 'N/A'}` +
(s.vectorString ? ` | Vector: ${s.vectorString}` : '')
));
}
if (data.is_kev) body.appendChild(modalSection('\u26A0 CISA KEV', 'This vulnerability is in the CISA Known Exploited Vulnerabilities catalog.'));
if (data.epss) body.appendChild(modalSection('EPSS',
`Probability: ${(data.epss.probability * 100).toFixed(2)}% | Percentile: ${(data.epss.percentile * 100).toFixed(2)}%`
));
/* Affected */
if (data.affected && data.affected.length > 0) {
const rows = normalizeAffected(data.affected);
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['Affected Products']),
el('div', { class: 'vuln-affected-table' }, [
el('div', { class: 'vuln-affected-row header' }, [el('span', {}, ['Vendor']), el('span', {}, ['Product']), el('span', {}, ['Versions'])]),
...rows.map(r => el('div', { class: 'vuln-affected-row' }, [el('span', {}, [r.vendor]), el('span', {}, [r.product]), el('span', {}, [r.versions])])),
]),
]));
}
/* Exploits section */
const exploits = data.exploits || [];
const exploitSection = el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['\u{1F4A3} Exploits & References']),
/* dynamic entries from DB */
...exploits.map(entry => {
const isStr = typeof entry === 'string';
const label = isStr ? entry : (entry.title || entry.url || 'Exploit');
const href = isStr
? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`)
: (entry.url || '#');
return el('div', { class: 'modal-exploit-item' }, [
refChip('\u26A1 ' + String(label).substring(0, 120), href, 'chip-exploit chip-exploit-detail'),
]);
}),
/* always-present search chips row */
el('div', { class: 'exploit-links-block', style: 'margin-top:10px;display:flex;flex-wrap:wrap;gap:6px' }, [
refChip('\u{1F50D} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(cveId)}`, 'chip-exploit chip-exploitdb'),
refChip('\u{1F431} GitHub PoC', `https://github.com/search?q=${encodeURIComponent(cveId)}&type=repositories`, 'chip-github'),
refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${encodeURIComponent(cveId)}`, 'chip-rapid7'),
refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cveId)}`, 'chip-nvd'),
refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${encodeURIComponent(cveId)}`, 'chip-mitre'),
]),
exploits.length === 0
? el('div', { style: 'opacity:.45;font-size:.8rem;margin-top:6px' }, ['No exploit records in DB yet — use \u201cSearch All Exploits\u201d to enrich.'])
: null,
].filter(Boolean));
body.appendChild(exploitSection);
/* References */
if (data.references && data.references.length > 0) {
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['References']),
...data.references.map(url => el('div', {}, [
el('a', { href: url, target: '_blank', rel: 'noopener', class: 'vuln-ref-link' }, [url]),
])),
]));
}
if (data.lastModified) body.appendChild(modalSection('Last Modified', formatDate(data.lastModified)));
if (!data.description && !data.cvss && !data.affected) {
body.appendChild(el('div', { style: 'opacity:.6;padding:20px;text-align:center' }, ['No enrichment data available.']));
}
} catch (err) {
if (body) { empty(body); body.appendChild(el('div', { style: 'color:var(--danger);padding:20px' }, [`Failed: ${err.message}`])); }
} finally {
modalInFlight = null;
}
}
function normalizeAffected(affected) {
return affected.map(item => {
const vendor = item.vendor || item.vendor_name || item.vendorName || 'N/A';
let product = item.product || item.product_name || item.productName || 'N/A';
if (Array.isArray(product)) product = product.join(', ');
else if (typeof product === 'object' && product !== null)
product = product.product || product.product_name || product.productName || 'N/A';
let versions = 'unspecified';
if (Array.isArray(item.versions)) {
versions = item.versions.map(ver => {
if (typeof ver === 'string') return ver;
const parts = [ver.version || ver.versionName || ver.version_value || ''];
if (ver.lessThan) parts.push(`< ${ver.lessThan}`);
if (ver.lessThanOrEqual) parts.push(`<= ${ver.lessThanOrEqual}`);
if (ver.status) parts.push(`(${ver.status})`);
return parts.join(' ');
}).join('; ');
} else if (typeof item.versions === 'string') {
versions = item.versions;
}
return { vendor, product: String(product), versions };
});
}
/* ════════════════════════════════════════
SEARCH / FILTER / SORT HANDLERS
═══════════════════════════════════════ */
function onSearch(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value; currentPage = 1; filterAndRender();
const b = e.target.nextElementSibling; if (b) b.classList.toggle('show', searchTerm.length > 0);
}, 300);
}
function clearSearch() {
const inp = $('#vuln-search'); if (inp) inp.value = '';
searchTerm = ''; currentPage = 1; filterAndRender();
const b = $('#vuln-search')?.nextElementSibling; if (b) b.classList.remove('show');
}
function switchView(view) {
currentView = view; currentPage = 1;
['cve','host','exploits'].forEach(v => { const b = $(`#vuln-view-${v}`); if (b) b.classList.toggle('active', v === view); });
filterAndRender();
}
function toggleActiveFilter() {
showActiveOnly = !showActiveOnly;
const b = $('#vuln-active-toggle'); if (b) b.classList.toggle('active', showActiveOnly);
currentPage = 1; filterAndRender();
}
function toggleSeverity(sev, btn) {
if (severityFilters.has(sev)) { severityFilters.delete(sev); btn.classList.remove('active'); }
else { severityFilters.add(sev); btn.classList.add('active'); }
currentPage = 1; filterAndRender();
}
function onSortChange() {
const f = $('#vuln-sort-field'); const d = $('#vuln-sort-dir');
if (f) sortField = f.value; if (d) sortDir = d.value;
currentPage = 1; filterAndRender();
}
function onDateChange() {
dateFrom = ($('#vuln-date-from') || {}).value || '';
dateTo = ($('#vuln-date-to') || {}).value || '';
currentPage = 1; filterAndRender();
}
function clearDateFilter() {
dateFrom = ''; dateTo = '';
const f = $('#vuln-date-from'); const t_ = $('#vuln-date-to');
if (f) f.value = ''; if (t_) t_.value = '';
currentPage = 1; filterAndRender();
}
function toggleHostCard(id) {
if (expandedHosts.has(id)) expandedHosts.delete(id); else expandedHosts.add(id);
const card = document.querySelector(`.vuln-card[data-id="${id}"]`);
if (card) card.classList.toggle('expanded');
}
/* ════════════════════════════════════════
PAGINATION
═══════════════════════════════════════ */
function renderPagination() {
const pag = $('#vuln-pagination'); if (!pag) return;
empty(pag);
if (historyMode || totalPages <= 1) return;
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++)
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
pag.appendChild(pageBtn('Next', currentPage < totalPages, () => changePage(currentPage + 1)));
pag.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${currentPage}/${totalPages}${filteredVulns.length} results`]));
}
function pageBtn(label, enabled, onclick, active = false) {
return el('button', {
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
onclick: enabled ? onclick : null, disabled: !enabled,
}, [label]);
}
function changePage(p) {
currentPage = Math.max(1, Math.min(totalPages, p)); filterAndRender();
const g = $('#vuln-grid'); if (g) g.scrollTop = 0;
}
/* ════════════════════════════════════════
EXPORT
═══════════════════════════════════════ */
function csvCell(val) {
const s = String(val ?? '');
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
return safe.includes(',') || safe.includes('"') || safe.includes('\n') ? `"${safe.replace(/"/g, '""')}"` : safe;
}
function exportCSV() {
const data = filteredVulns.length ? filteredVulns : vulnerabilities;
if (!data.length) return;
const rows = [['CVE ID','IP','Hostname','Port','Severity','CVSS','Status','First Seen','Last Seen','KEV','Has Exploit','EPSS'].join(',')];
data.forEach(v => rows.push([
v.vuln_id, v.ip, v.hostname, v.port, v.severity,
v.cvss_score != null ? parseFloat(v.cvss_score).toFixed(1) : '',
v.is_active === 1 ? 'Active' : 'Remediated',
v.first_seen, v.last_seen,
v.is_kev ? 'Yes' : 'No',
v.has_exploit ? 'Yes' : 'No',
v.epss != null ? (v.epss * 100).toFixed(2) + '%' : '',
].map(csvCell).join(',')));
downloadBlob(rows.join('\n'), `vulnerabilities_${isoDate()}.csv`, 'text/csv');
}
function exportJSON() {
const data = filteredVulns.length ? filteredVulns : vulnerabilities;
if (!data.length) return;
downloadBlob(JSON.stringify(data, null, 2), `vulnerabilities_${isoDate()}.json`, 'application/json');
}
function downloadBlob(content, filename, type) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement('a'); a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
}
/* ════════════════════════════════════════
MODAL CLOSE
═══════════════════════════════════════ */
function closeModal() {
const modal = $('#vuln-modal'); if (modal) modal.classList.remove('show');
modalInFlight = null;
if (poller) poller.start(); // resume polling
}
function onModalBackdrop(e) { if (e.target.classList.contains('vuln-modal')) closeModal(); }
/* ════════════════════════════════════════
HELPERS
═══════════════════════════════════════ */
function metaItem(label, value) {
return el('div', { class: 'meta-item' }, [
el('span', { class: 'meta-label' }, [label + ':']),
el('span', { class: 'meta-value' }, [String(value ?? 'N/A')]),
]);
}
function modalSection(title, text) {
return el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, [title]),
el('div', { class: 'modal-section-text' }, [String(text)]),
]);
}
function emptyState(msg) {
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\u{1F50D}']),
msg,
]);
}
function sevPill(sev, count) {
return el('span', { class: `severity-badge severity-${sev}` }, [`${count} ${sev}`]);
}
function formatDate(d) {
if (!d) return 'Unknown';
try { return new Date(d).toLocaleString('en-US', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); }
catch { return String(d); }
}
function isoDate() { return new Date().toISOString().split('T')[0]; }
function countSeverities(vulns) {
const c = { critical: 0, high: 0, medium: 0, low: 0 };
vulns.forEach(v => { if (v.is_active === 1 && c[v.severity] !== undefined) c[v.severity]++; });
return c;
}
function sortVulnsByPriority(vulns) {
return [...vulns].sort((a, b) => {
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
return (SEVERITY_ORDER[b.severity] || 0) - (SEVERITY_ORDER[a.severity] || 0);
});
}

801
web/js/pages/web-enum.js Normal file
View File

@@ -0,0 +1,801 @@
/**
* Web Enum page module.
* Displays web enumeration/directory brute-force results with filtering,
* sorting, pagination, detail modal, and JSON/CSV export.
* Endpoint: GET /api/webenum/results?page=N&limit=M
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'web-enum';
const MAX_PAGES_FETCH = 200;
const FETCH_LIMIT = 500;
const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500, 0]; // 0 = All
const ANSI_RE = /[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g;
/* ── state ── */
let tracker = null;
let allData = [];
let filteredData = [];
let currentPage = 1;
let itemsPerPage = 50;
let sortField = 'scan_date';
let sortDirection = 'desc';
let exactStatusFilter = null;
let serverTotal = 0;
let fetchedLimit = false;
/* filter state */
let searchText = '';
let filterHost = '';
let filterStatusFamily = '';
let filterPort = '';
let filterDate = '';
let searchDebounceId = null;
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
container.appendChild(buildShell());
await fetchAllData();
}
export function unmount() {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
searchDebounceId = null;
if (tracker) { tracker.cleanupAll(); tracker = null; }
allData = [];
filteredData = [];
currentPage = 1;
itemsPerPage = 50;
sortField = 'scan_date';
sortDirection = 'desc';
exactStatusFilter = null;
serverTotal = 0;
fetchedLimit = false;
searchText = '';
filterHost = '';
filterStatusFamily = '';
filterPort = '';
filterDate = '';
}
/* ══════════════════════════════════════════════════════════════
Shell
══════════════════════════════════════════════════════════════ */
function buildShell() {
return el('div', { class: 'webenum-container' }, [
/* stats bar */
el('div', { class: 'stats-bar', id: 'we-stats' }, [
statItem('we-stat-total', 'Total Results'),
statItem('we-stat-hosts', 'Unique Hosts'),
statItem('we-stat-success', 'Success (2xx)'),
statItem('we-stat-errors', 'Errors (4xx/5xx)'),
]),
/* controls row */
el('div', { class: 'webenum-controls' }, [
/* text search */
el('div', { class: 'global-search-container' }, [
el('input', {
type: 'text', class: 'global-search-input', id: 'we-search',
placeholder: t('common.search') || 'Search host, IP, directory, status\u2026',
oninput: onSearchInput,
}),
el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']),
]),
el('div', { class: 'webenum-main-actions' }, [
el('button', { class: 'vuln-btn', onclick: () => fetchAllData() }, ['Refresh']),
]),
/* dropdown filters */
el('div', { class: 'webenum-filters' }, [
buildSelect('we-filter-host', 'All Hosts', onHostFilter),
buildSelect('we-filter-status', 'All Status', onStatusFamilyFilter),
buildSelect('we-filter-port', 'All Ports', onPortFilter),
el('input', {
type: 'date', class: 'webenum-date-input', id: 'we-filter-date',
onchange: onDateFilter,
}),
]),
/* export buttons */
el('div', { class: 'webenum-export-btns' }, [
el('button', { class: 'vuln-btn', onclick: () => exportData('json') }, ['Export JSON']),
el('button', { class: 'vuln-btn', onclick: () => exportData('csv') }, ['Export CSV']),
]),
]),
/* status legend chips */
el('div', { class: 'webenum-status-legend', id: 'we-status-legend' }),
/* table container */
el('div', { class: 'webenum-table-wrap', id: 'we-table-wrap' }),
/* pagination */
el('div', { class: 'webenum-pagination', id: 'we-pagination' }),
/* detail modal */
el('div', { class: 'vuln-modal', id: 'we-modal', onclick: onModalBackdrop }, [
el('div', { class: 'vuln-modal-content' }, [
el('div', { class: 'vuln-modal-header' }, [
el('span', { class: 'vuln-modal-title', id: 'we-modal-title' }),
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
]),
el('div', { class: 'vuln-modal-body', id: 'we-modal-body' }),
]),
]),
]);
}
function statItem(id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function buildSelect(id, defaultLabel, handler) {
return el('select', { class: 'webenum-filter-select', id, onchange: handler }, [
el('option', { value: '' }, [defaultLabel]),
]);
}
/* ══════════════════════════════════════════════════════════════
Data fetching — paginate through all server pages
══════════════════════════════════════════════════════════════ */
async function fetchAllData() {
const loading = $('#we-table-wrap');
if (loading) {
empty(loading);
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026']));
}
const ac = tracker ? tracker.trackAbortController() : null;
const signal = ac ? ac.signal : undefined;
let accumulated = [];
let page = 1;
serverTotal = 0;
fetchedLimit = false;
try {
while (page <= MAX_PAGES_FETCH) {
const url = `/api/webenum/results?page=${page}&limit=${FETCH_LIMIT}`;
const data = await api.get(url, { signal, timeout: 15000 });
const results = Array.isArray(data.results) ? data.results : [];
if (data.total != null) serverTotal = data.total;
if (results.length === 0) break;
accumulated = accumulated.concat(results);
/* all fetched */
if (serverTotal > 0 && accumulated.length >= serverTotal) break;
/* page was not full — last page */
if (results.length < FETCH_LIMIT) break;
page++;
}
if (page > MAX_PAGES_FETCH) fetchedLimit = true;
} catch (err) {
if (err.name === 'ApiError' && err.message === 'Aborted') return;
console.warn(`[${PAGE}] fetch error:`, err.message);
} finally {
if (ac && tracker) tracker.removeAbortController(ac);
}
allData = accumulated.map(normalizeRow);
populateFilterDropdowns();
applyFilters();
}
/* ── row normalization ── */
function normalizeRow(row) {
const host = (row.host || row.hostname || '').toString();
let directory = (row.directory || '').toString().replace(ANSI_RE, '');
return {
id: row.id,
host: host,
ip: (row.ip || '').toString(),
mac: (row.mac || '').toString(),
port: row.port != null ? Number(row.port) : 0,
directory: directory,
status: row.status != null ? Number(row.status) : 0,
size: row.size != null ? Number(row.size) : 0,
scan_date: row.scan_date || '',
response_time: row.response_time != null ? Number(row.response_time) : 0,
content_type: (row.content_type || '').toString(),
};
}
/* ══════════════════════════════════════════════════════════════
Filter dropdowns — populate from unique values
══════════════════════════════════════════════════════════════ */
function populateFilterDropdowns() {
populateSelect('we-filter-host', 'All Hosts',
[...new Set(allData.map(r => r.host).filter(Boolean))].sort());
const families = [...new Set(allData.map(r => statusFamily(r.status)).filter(Boolean))].sort();
populateSelect('we-filter-status', 'All Status', families);
const ports = [...new Set(allData.map(r => r.port).filter(p => p > 0))].sort((a, b) => a - b);
populateSelect('we-filter-port', 'All Ports', ports.map(String));
}
function populateSelect(id, defaultLabel, options) {
const sel = $(`#${id}`);
if (!sel) return;
const current = sel.value;
empty(sel);
sel.appendChild(el('option', { value: '' }, [defaultLabel]));
options.forEach(opt => {
sel.appendChild(el('option', { value: opt }, [opt]));
});
if (current && options.includes(current)) sel.value = current;
}
/* ══════════════════════════════════════════════════════════════
Filter & sort pipeline
══════════════════════════════════════════════════════════════ */
function applyFilters() {
const needle = searchText.toLowerCase();
filteredData = allData.filter(row => {
/* exact status chip filter */
if (exactStatusFilter != null && row.status !== exactStatusFilter) return false;
/* text search */
if (needle) {
const hay = `${row.host} ${row.ip} ${row.directory} ${row.status}`.toLowerCase();
if (!hay.includes(needle)) return false;
}
/* host dropdown */
if (filterHost && row.host !== filterHost) return false;
/* status family dropdown */
if (filterStatusFamily && statusFamily(row.status) !== filterStatusFamily) return false;
/* port dropdown */
if (filterPort && String(row.port) !== filterPort) return false;
/* date filter */
if (filterDate) {
const rowDate = (row.scan_date || '').substring(0, 10);
if (rowDate !== filterDate) return false;
}
return true;
});
applySort();
currentPage = 1;
updateStats();
renderStatusLegend();
renderTable();
renderPagination();
}
function applySort() {
const dir = sortDirection === 'asc' ? 1 : -1;
const field = sortField;
filteredData.sort((a, b) => {
let va = a[field];
let vb = b[field];
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') {
return (va - vb) * dir;
}
/* date string comparison */
if (field === 'scan_date') {
const da = new Date(va).getTime() || 0;
const db = new Date(vb).getTime() || 0;
return (da - db) * dir;
}
return String(va).localeCompare(String(vb)) * dir;
});
}
/* ══════════════════════════════════════════════════════════════
Stats bar
══════════════════════════════════════════════════════════════ */
function updateStats() {
const totalLabel = fetchedLimit
? `${filteredData.length} (truncated)`
: String(filteredData.length);
setStatVal('we-stat-total', totalLabel);
setStatVal('we-stat-hosts', new Set(filteredData.map(r => r.host || r.ip)).size);
setStatVal('we-stat-success', filteredData.filter(r => r.status >= 200 && r.status < 300).length);
setStatVal('we-stat-errors', filteredData.filter(r => r.status >= 400).length);
}
function setStatVal(id, val) {
const e = $(`#${id}`);
if (e) e.textContent = String(val);
}
/* ══════════════════════════════════════════════════════════════
Status legend chips
══════════════════════════════════════════════════════════════ */
function renderStatusLegend() {
const container = $('#we-status-legend');
if (!container) return;
empty(container);
/* gather unique status codes from current allData (unfiltered view) */
const codes = [...new Set(allData.map(r => r.status))].sort((a, b) => a - b);
if (codes.length === 0) return;
codes.forEach(code => {
const count = allData.filter(r => r.status === code).length;
const isActive = exactStatusFilter === code;
const chip = el('span', {
class: `webenum-status-chip ${statusClass(code)} ${isActive ? 'active' : ''}`,
onclick: () => {
if (exactStatusFilter === code) {
exactStatusFilter = null;
} else {
exactStatusFilter = code;
}
/* clear active class on all chips, re-apply via full filter cycle */
$$('.webenum-status-chip', container).forEach(c => c.classList.remove('active'));
applyFilters();
},
}, [`${code} (${count})`]);
container.appendChild(chip);
});
}
/* ══════════════════════════════════════════════════════════════
Table rendering
══════════════════════════════════════════════════════════════ */
function renderTable() {
const wrap = $('#we-table-wrap');
if (!wrap) return;
empty(wrap);
if (filteredData.length === 0) {
wrap.appendChild(emptyState('No web enumeration results found'));
return;
}
/* current page slice */
const pageData = getPageSlice();
/* column definitions */
const columns = [
{ key: 'host', label: 'Host' },
{ key: 'ip', label: 'IP' },
{ key: 'port', label: 'Port' },
{ key: 'directory', label: 'Directory' },
{ key: 'status', label: 'Status' },
{ key: 'size', label: 'Size' },
{ key: 'scan_date', label: 'Scan Date' },
{ key: '_actions', label: 'Actions' },
];
/* thead */
const headerCells = columns.map(col => {
if (col.key === '_actions') {
return el('th', {}, [col.label]);
}
const isSorted = sortField === col.key;
const arrow = isSorted ? (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC') : '';
return el('th', {
class: `sortable ${isSorted ? 'sort-' + sortDirection : ''}`,
style: 'cursor:pointer;user-select:none;',
onclick: () => onSortColumn(col.key),
}, [col.label + arrow]);
});
const thead = el('thead', {}, [el('tr', {}, headerCells)]);
/* tbody */
const rows = pageData.map(row => {
const url = buildUrl(row);
return el('tr', {
class: 'webenum-row',
style: 'cursor:pointer;',
onclick: (e) => {
/* ignore if click was on an anchor */
if (e.target.tagName === 'A') return;
showDetailModal(row);
},
}, [
el('td', {}, [row.host || '-']),
el('td', {}, [row.ip || '-']),
el('td', {}, [row.port ? String(row.port) : '-']),
el('td', { class: 'webenum-dir-cell', title: row.directory }, [row.directory || '/']),
el('td', {}, [statusBadge(row.status)]),
el('td', {}, [formatSize(row.size)]),
el('td', {}, [formatDate(row.scan_date)]),
el('td', {}, [
url
? el('a', {
href: url, target: '_blank', rel: 'noopener noreferrer',
class: 'webenum-link', title: url,
onclick: (e) => e.stopPropagation(),
}, ['Open'])
: el('span', { class: 'muted' }, ['-']),
]),
]);
});
const tbody = el('tbody', {}, rows);
const table = el('table', { class: 'webenum-table' }, [thead, tbody]);
wrap.appendChild(el('div', { class: 'table-inner' }, [table]));
}
function getPageSlice() {
if (itemsPerPage === 0) return filteredData; // All
const start = (currentPage - 1) * itemsPerPage;
return filteredData.slice(start, start + itemsPerPage);
}
function getTotalPages() {
if (itemsPerPage === 0) return 1;
return Math.max(1, Math.ceil(filteredData.length / itemsPerPage));
}
/* ══════════════════════════════════════════════════════════════
Pagination
══════════════════════════════════════════════════════════════ */
function renderPagination() {
const pag = $('#we-pagination');
if (!pag) return;
empty(pag);
const total = getTotalPages();
/* per-page selector */
const perPageSel = el('select', { class: 'webenum-filter-select webenum-perpage', onchange: onPerPageChange }, []);
PER_PAGE_OPTIONS.forEach(n => {
const label = n === 0 ? 'All' : String(n);
const opt = el('option', { value: String(n) }, [label]);
if (n === itemsPerPage) opt.selected = true;
perPageSel.appendChild(opt);
});
pag.appendChild(el('div', { class: 'webenum-perpage-wrap' }, [
el('span', { class: 'stat-label' }, ['Per page:']),
perPageSel,
]));
if (total <= 1 && itemsPerPage !== 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
]));
return;
}
if (itemsPerPage === 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
]));
return;
}
/* Prev */
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
/* numbered buttons */
const start = Math.max(1, currentPage - 2);
const end = Math.min(total, start + 4);
for (let i = start; i <= end; i++) {
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
}
/* Next */
pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1)));
/* info */
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Page ${currentPage} of ${total} (${filteredData.length} results)`,
]));
}
function pageBtn(label, enabled, onclick, active = false) {
return el('button', {
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
onclick: enabled ? onclick : null,
disabled: !enabled,
}, [label]);
}
function changePage(p) {
const total = getTotalPages();
currentPage = Math.max(1, Math.min(total, p));
renderTable();
renderPagination();
const wrap = $('#we-table-wrap');
if (wrap) wrap.scrollTop = 0;
}
function onPerPageChange(e) {
itemsPerPage = parseInt(e.target.value, 10);
currentPage = 1;
renderTable();
renderPagination();
}
/* ══════════════════════════════════════════════════════════════
Sort handler
══════════════════════════════════════════════════════════════ */
function onSortColumn(key) {
if (sortField === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = key;
sortDirection = 'asc';
}
applySort();
renderTable();
renderPagination();
}
/* ══════════════════════════════════════════════════════════════
Filter handlers
══════════════════════════════════════════════════════════════ */
function onSearchInput(e) {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
const val = e.target.value;
searchDebounceId = tracker
? tracker.trackTimeout(() => {
searchText = val;
applyFilters();
const btn = e.target.nextElementSibling;
if (btn) btn.classList.toggle('show', val.length > 0);
}, 300)
: setTimeout(() => {
searchText = val;
applyFilters();
}, 300);
}
function clearSearch() {
const inp = $('#we-search');
if (inp) inp.value = '';
searchText = '';
applyFilters();
const btn = inp ? inp.nextElementSibling : null;
if (btn) btn.classList.remove('show');
}
function onHostFilter(e) {
filterHost = e.target.value;
applyFilters();
}
function onStatusFamilyFilter(e) {
filterStatusFamily = e.target.value;
/* clear exact chip filter when dropdown changes */
exactStatusFilter = null;
applyFilters();
}
function onPortFilter(e) {
filterPort = e.target.value;
applyFilters();
}
function onDateFilter(e) {
filterDate = e.target.value || '';
applyFilters();
}
/* ══════════════════════════════════════════════════════════════
Detail modal
══════════════════════════════════════════════════════════════ */
function showDetailModal(row) {
const modal = $('#we-modal');
const title = $('#we-modal-title');
const body = $('#we-modal-body');
if (!modal || !title || !body) return;
const url = buildUrl(row);
title.textContent = `${row.host || row.ip}${row.directory || '/'}`;
empty(body);
const fields = [
['Host', row.host],
['IP', row.ip],
['MAC', row.mac],
['Port', row.port],
['Directory', row.directory],
['Status', row.status],
['Size', formatSize(row.size)],
['Content-Type', row.content_type],
['Response Time', row.response_time ? row.response_time + ' ms' : '-'],
['Scan Date', formatDate(row.scan_date)],
['URL', url || 'N/A'],
];
fields.forEach(([label, value]) => {
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, [label]),
el('div', { class: 'modal-section-text' }, [
label === 'Status'
? statusBadge(value)
: String(value != null ? value : '-'),
]),
]));
});
/* action buttons */
const actions = el('div', { class: 'webenum-modal-actions' }, []);
if (url) {
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
window.open(url, '_blank', 'noopener,noreferrer');
}}, ['Open URL']));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
copyText(url);
}}, ['Copy URL']));
}
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'json') }, ['Export JSON']));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'csv') }, ['Export CSV']));
body.appendChild(actions);
modal.classList.add('show');
}
function closeModal() {
const modal = $('#we-modal');
if (modal) modal.classList.remove('show');
}
function onModalBackdrop(e) {
if (e.target.classList.contains('vuln-modal')) closeModal();
}
/* ══════════════════════════════════════════════════════════════
Export — JSON & CSV
══════════════════════════════════════════════════════════════ */
function exportData(format) {
const data = filteredData.length > 0 ? filteredData : allData;
if (data.length === 0) return;
const dateStr = new Date().toISOString().split('T')[0];
if (format === 'json') {
const json = JSON.stringify(data, null, 2);
downloadBlob(json, `webenum_results_${dateStr}.json`, 'application/json');
} else {
const csv = buildCSV(data);
downloadBlob(csv, `webenum_results_${dateStr}.csv`, 'text/csv');
}
}
function exportSingleResult(row, format) {
const dateStr = new Date().toISOString().split('T')[0];
if (format === 'json') {
downloadBlob(JSON.stringify(row, null, 2), `webenum_${row.host}_${dateStr}.json`, 'application/json');
} else {
downloadBlob(buildCSV([row]), `webenum_${row.host}_${dateStr}.csv`, 'text/csv');
}
}
function buildCSV(data) {
const headers = ['Host', 'IP', 'MAC', 'Port', 'Directory', 'Status', 'Size', 'Content-Type', 'Response Time', 'Scan Date', 'URL'];
const rows = [headers.join(',')];
data.forEach(r => {
const url = buildUrl(r) || '';
const values = [
r.host, r.ip, r.mac, r.port, r.directory, r.status,
r.size, r.content_type, r.response_time, r.scan_date, url,
].map(v => {
const s = String(v != null ? v : '');
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"` : s;
});
rows.push(values.join(','));
});
return rows.join('\n');
}
function downloadBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* ══════════════════════════════════════════════════════════════
Helpers
══════════════════════════════════════════════════════════════ */
/** Status family string: '2xx', '3xx', '4xx', '5xx' */
function statusFamily(code) {
code = Number(code) || 0;
if (code >= 200 && code < 300) return '2xx';
if (code >= 300 && code < 400) return '3xx';
if (code >= 400 && code < 500) return '4xx';
if (code >= 500) return '5xx';
return '';
}
/** CSS class for status code */
function statusClass(code) {
code = Number(code) || 0;
if (code >= 200 && code < 300) return 'status-2xx';
if (code >= 300 && code < 400) return 'status-3xx';
if (code >= 400 && code < 500) return 'status-4xx';
if (code >= 500) return 'status-5xx';
return '';
}
/** Status badge element */
function statusBadge(code) {
return el('span', { class: `webenum-status-badge ${statusClass(code)}` }, [String(code)]);
}
/** Build full URL from row data */
function buildUrl(row) {
if (!row.host && !row.ip) return '';
const hostname = row.host || row.ip;
const port = Number(row.port) || 80;
const proto = port === 443 ? 'https' : 'http';
const portPart = (port === 80 || port === 443) ? '' : `:${port}`;
const dir = row.directory || '/';
return `${proto}://${hostname}${portPart}${dir}`;
}
/** Format byte size to human-readable */
function formatSize(bytes) {
bytes = Number(bytes) || 0;
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
/** Format date string */
function formatDate(d) {
if (!d) return '-';
try {
const date = new Date(d);
if (isNaN(date.getTime())) return String(d);
return date.toLocaleDateString();
} catch {
return String(d);
}
}
/** Empty state */
function emptyState(msg) {
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\uD83D\uDD0D']),
msg,
]);
}
/** Copy text to clipboard */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* noop */ }
document.body.removeChild(ta);
}

762
web/js/pages/zombieland.js Normal file
View File

@@ -0,0 +1,762 @@
/**
* Zombieland page module — C2 (Command & Control) agent management.
* Uses Server-Sent Events (SSE) via /c2/events for real-time updates.
* The EventSource connection is closed in unmount() to prevent leaks.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'zombieland';
const L = (key, fallback, vars = {}) => {
const v = t(key, vars);
return v === key ? fallback : v;
};
/* ——— Presence thresholds (ms) ——— */
const PRESENCE = { GRACE: 30000, WARN: 60000, ORANGE: 100000, RED: 160000 };
/* ——— ECG waveform paths ——— */
const ECG_PQRST = 'M0,21 L15,21 L18,19 L20,21 L30,21 L32,23 L34,21 L40,21 L42,12 L44,30 L46,8 L48,35 L50,21 L60,21 L65,21 L70,19 L72,21 L85,21 L90,21 L100,21 L110,21 L115,19 L118,21 L130,21 L132,23 L134,21 L140,21 L142,12 L144,30 L146,8 L148,35 L150,21 L160,21 L170,21 L180,21 L190,21 L200,21';
const ECG_FLAT = 'M0,21 L200,21';
/* ——— State ——— */
let tracker = null;
let poller = null;
let disposeSidebarLayout = null;
let eventSource = null;
let agents = new Map(); // id -> agent object
let selectedAgents = new Set();
let searchTerm = '';
let c2Running = false;
let c2Port = null;
let sseHealthy = false;
let commandHistory = [];
let historyIndex = -1;
function loadStylesheet(path, id) {
const link = el('link', {
rel: 'stylesheet',
href: path,
id: `style-${id}`
});
document.head.appendChild(link);
return () => {
const styleElement = document.getElementById(`style-${id}`);
if (styleElement) {
styleElement.remove();
}
};
}
/* ================================================================
* Lifecycle
* ================================================================ */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
// Load page-specific styles and track them for cleanup
const unloadStyles = loadStylesheet('/web/css/zombieland.css', PAGE);
tracker.trackResource(unloadStyles);
agents.clear();
selectedAgents.clear();
searchTerm = '';
c2Running = false;
c2Port = null;
sseHealthy = false;
commandHistory = [];
historyIndex = -1;
const shell = buildShell();
container.appendChild(shell);
container.appendChild(buildGenerateClientModal());
container.appendChild(buildFileBrowserModal());
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.zl-sidebar',
mainSelector: '.zl-main',
storageKey: 'sidebar:zombieland',
mobileBreakpoint: 900,
toggleLabel: t('common.menu') || 'Menu',
});
await refreshState();
syncSearchClearButton();
connectSSE();
poller = new Poller(refreshState, 10000, { immediate: false });
poller.start();
tracker.trackInterval(tickPresence, 1000);
}
export function unmount() {
if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch { } disposeSidebarLayout = null; }
if (eventSource) { eventSource.close(); eventSource = null; }
sseHealthy = false;
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
agents.clear();
selectedAgents.clear();
searchTerm = '';
commandHistory = [];
historyIndex = -1;
}
/* ================================================================
* Shell & Modals
* ================================================================ */
function buildShell() {
return el('div', { class: 'zombieland-container page-with-sidebar' }, [
el('aside', { class: 'zl-sidebar page-sidebar' }, [
el('div', { class: 'sidehead' }, [
el('div', { class: 'sidetitle' }, [t('nav.zombieland') || 'Zombieland']),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide') || 'Hide']),
]),
el('div', { class: 'sidecontent' }, [
el('div', { class: 'zl-stats-grid' }, [
statItem('zl-stat-total', L('zombieland.totalAgents', 'Total')),
statItem('zl-stat-alive', L('zombieland.alive', 'Online')),
statItem('zl-stat-avg-cpu', 'Avg CPU'),
statItem('zl-stat-avg-ram', 'Avg RAM'),
statItem('zl-stat-c2', L('zombieland.c2Status', 'C2 Port')),
]),
el('div', { class: 'zl-toolbar' }, [
el('button', { class: 'btn btn-icon', onclick: onRefresh, title: t('common.refresh') }, [el('i', { 'data-lucide': 'refresh-cw' })]),
el('button', { class: 'btn', onclick: onGenerateClient }, [el('i', { 'data-lucide': 'plus-circle' }), ' ' + t('zombie.generateClient')]),
el('button', { class: 'btn btn-primary', onclick: onStartC2 }, [el('i', { 'data-lucide': 'play' }), ' ' + t('zombie.startC2')]),
el('button', { class: 'btn btn-danger', onclick: onStopC2 }, [el('i', { 'data-lucide': 'square' }), ' ' + t('zombie.stopC2')]),
el('button', { class: 'btn', onclick: onCheckStale }, [el('i', { 'data-lucide': 'search' }), ' ' + t('zombie.checkStale')]),
el('button', { class: 'btn btn-danger', onclick: onPurgeStale, title: t('zombie.purgeStaleHint') }, [el('i', { 'data-lucide': 'trash-2' }), ' ' + t('zombie.purgeStale')]),
]),
]),
]),
el('div', { class: 'zl-main page-main' }, [
el('div', { class: 'zl-main-grid' }, [
el('div', { class: 'zl-console-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [t('console.title')]),
el('div', { class: 'zl-quickbar' }, [
quickCmd('sysinfo'), quickCmd('pwd'), quickCmd('ls -la'), quickCmd('ps aux'), quickCmd('ip a'),
]),
el('button', { class: 'btn btn-sm btn-icon', onclick: clearConsole, title: t('zombie.clearConsole') }, [el('i', { 'data-lucide': 'trash-2' })]),
]),
el('div', { class: 'zl-console-output', id: 'zl-console-output' }),
el('div', { class: 'zl-console-input-row' }, [
el('select', { class: 'zl-target-select', id: 'zl-target-select' }, [
el('option', { value: 'broadcast' }, [t('zombie.allAgents')]),
el('option', { value: 'selected' }, [t('zombie.selectedAgents')]),
]),
el('input', { type: 'text', class: 'zl-cmd-input', id: 'zl-cmd-input', placeholder: t('zombie.enterCommand'), onkeydown: onCmdKeyDown }),
el('button', { class: 'btn btn-primary', onclick: onSendCommand }, [el('i', { 'data-lucide': 'send' }), ' ' + t('common.send')]),
]),
]),
el('div', { class: 'zl-agents-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [t('zombie.agents'), ' (', el('span', { id: 'zl-agent-count' }, ['0']), ')']),
el('div', { class: 'zl-toolbar-left' }, [
el('input', { type: 'text', class: 'zl-search-input', id: 'zl-search', placeholder: t('zombie.fileBrowser'), oninput: onSearch }),
el('button', { class: 'zl-search-clear', onclick: clearSearch }, [el('i', { 'data-lucide': 'x' })]),
]),
el('button', { class: 'btn btn-sm btn-icon', onclick: onSelectAll, title: t('zombie.selectAll') }, [el('i', { 'data-lucide': 'check-square' })]),
el('button', { class: 'btn btn-sm btn-icon', onclick: onDeselectAll, title: t('zombie.deselectAll') }, [el('i', { 'data-lucide': 'square' })]),
]),
el('div', { class: 'zl-agents-list', id: 'zl-agents-list', onclick: onAgentListClick }),
]),
]),
el('div', { class: 'zl-logs-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [el('i', { 'data-lucide': 'file-text' }), ' ' + t('zombie.systemLogs')]),
el('button', { class: 'btn btn-sm btn-icon', onclick: clearLogs, title: t('zombie.clearLogs') }, [el('i', { 'data-lucide': 'trash-2' })]),
]),
el('div', { class: 'zl-logs-output', id: 'zl-logs-output' }),
]),
]),
]);
}
function statItem(id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function quickCmd(cmd) {
return el('button', {
class: 'quick-cmd', onclick: () => {
const input = $('#zl-cmd-input');
if (input) { input.value = cmd; input.focus(); }
}
}, [cmd]);
}
function buildGenerateClientModal() {
return el('div', { id: 'generateModal', class: 'modal', style: 'display:none;' }, [
el('div', { class: 'modal-content' }, [
el('h3', { class: 'modal-title' }, [t('zombie.generateClient')]),
el('div', { class: 'form-grid' }, [
el('label', {}, [t('zombie.clientId')]),
el('input', { id: 'clientId', type: 'text', class: 'input', placeholder: 'zombie01' }),
el('label', {}, [t('common.platform')]),
el('select', { id: 'clientPlatform', class: 'select' }, [
el('option', { value: 'linux' }, ['Linux']),
el('option', { value: 'windows' }, ['Windows']),
el('option', { value: 'macos' }, ['macOS']),
el('option', { value: 'universal' }, ['Universal (Python)']),
]),
el('label', {}, [t('zombie.labCreds')]),
el('div', { class: 'grid-col-2' }, [
el('input', { id: 'labUser', type: 'text', class: 'input', placeholder: t('common.username') }),
el('input', { id: 'labPass', type: 'password', class: 'input', placeholder: t('common.password') }),
]),
]),
el('div', { class: 'deploy-options' }, [
el('h4', {}, [t('zombie.deployOptions')]),
el('label', { class: 'checkbox-label' }, [
el('input', { type: 'checkbox', id: 'deploySSH', onchange: (e) => { $('#sshOptions').classList.toggle('hidden', !e.target.checked); } }),
el('span', {}, [t('zombie.deployViaSSH')]),
]),
el('div', { id: 'sshOptions', class: 'hidden form-grid' }, [
el('label', {}, ['SSH Host']), el('input', { id: 'sshHost', type: 'text', class: 'input' }),
el('label', {}, ['SSH User']), el('input', { id: 'sshUser', type: 'text', class: 'input' }),
el('label', {}, ['SSH Pass']), el('input', { id: 'sshPass', type: 'password', class: 'input' }),
]),
]),
el('div', { class: 'modal-actions' }, [
el('button', { class: 'btn', onclick: () => $('#generateModal').style.display = 'none' }, [t('common.cancel')]),
el('button', { class: 'btn btn-primary', onclick: onConfirmGenerate }, [t('common.generate')]),
]),
]),
]);
}
function buildFileBrowserModal() {
return el('div', { id: 'fileBrowserModal', class: 'modal', style: 'display:none;' }, [
el('div', { class: 'modal-content' }, [
el('h3', { class: 'modal-title' }, [t('zombie.fileBrowser'), ' - ', el('span', { id: 'browserAgent' })]),
el('div', { class: 'file-browser-nav' }, [
el('input', { id: 'browserPath', type: 'text', class: 'input flex-grow' }),
el('button', { class: 'btn', onclick: browseDirectory }, [t('common.browse')]),
el('button', { class: 'btn', onclick: onUploadFile }, [t('common.upload')]),
]),
el('div', { id: 'fileList', class: 'file-list' }),
el('div', { class: 'modal-actions' }, [
el('button', { class: 'btn', onclick: () => $('#fileBrowserModal').style.display = 'none' }, [t('common.close')]),
]),
]),
]);
}
/* ================================================================
* Data fetching & SSE
* ================================================================ */
async function refreshState() {
try {
if (sseHealthy && eventSource && eventSource.readyState === EventSource.OPEN) return;
const [status, agentList] = await Promise.all([
api.get('/c2/status').catch(() => null),
api.get('/c2/agents').catch(() => null),
]);
if (status) { c2Running = !!status.running; c2Port = status.port || null; }
if (Array.isArray(agentList)) {
for (const a of agentList) {
const id = a.id || a.agent_id || a.client_id;
if (!id) continue;
const existing = agents.get(id) || {};
const merged = { ...existing, ...a, id, last_seen: maxTimestamp(existing.last_seen, a.last_seen) };
agents.set(id, merged);
}
}
renderAgents();
updateStats();
} catch (err) { console.warn(`[${PAGE}] refreshState error:`, err.message); }
}
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/c2/events');
eventSource.onopen = () => { sseHealthy = true; systemLog('info', 'Connected to C2 event stream'); };
eventSource.onerror = () => { sseHealthy = false; systemLog('error', 'C2 event stream connection lost'); };
eventSource.addEventListener('status', (e) => {
try { const data = JSON.parse(e.data); c2Running = !!data.running; c2Port = data.port || null; updateStats(); } catch { }
});
eventSource.addEventListener('telemetry', (e) => {
try {
const data = JSON.parse(e.data);
const id = data.id || data.agent_id; if (!id) return;
const now = Date.now();
const existing = agents.get(id) || {};
const agent = { ...existing, ...data, id, last_seen: now };
agents.set(id, agent);
if (computePresence(existing, now).status !== computePresence(agent, now).status) {
systemLog('success', `Agent ${agent.hostname || id} telemetry received.`);
}
const card = $('[data-agent-id="' + id + '"]');
if (card) { card.classList.add('pulse'); tracker.trackTimeout(() => card.classList.remove('pulse'), 600); }
renderAgents();
updateStats();
} catch { }
});
eventSource.addEventListener('log', (e) => { try { const d = JSON.parse(e.data); systemLog(d.level || 'info', d.text || ''); } catch { } });
eventSource.addEventListener('console', (e) => { try { const d = JSON.parse(e.data); consoleLog(d.kind || 'RX', d.text || '', d.target || null); } catch { } });
}
/* ================================================================
* Presence, Ticking, and Rendering
* ================================================================ */
function computePresence(agent, now) {
if (!agent || !agent.last_seen) return { status: 'offline', delta: null, color: 'red', bpm: 0 };
const last = parseTs(agent.last_seen);
if (isNaN(last)) return { status: 'offline', delta: null, color: 'red', bpm: 0 };
const delta = now - last;
if (delta < PRESENCE.GRACE) return { status: 'online', delta, color: 'green', bpm: 55 };
if (delta < PRESENCE.WARN) return { status: 'online', delta, color: 'green', bpm: 40 };
if (delta < PRESENCE.ORANGE) return { status: 'idle', delta, color: 'yellow', bpm: 22 };
if (delta < PRESENCE.RED) return { status: 'idle', delta, color: 'orange', bpm: 12 };
return { status: 'offline', delta, color: 'red', bpm: 0 };
}
function tickPresence() {
const now = Date.now();
document.querySelectorAll('.zl-agent-card').forEach(card => {
const agentId = card.dataset.agentId;
const agent = agents.get(agentId);
if (!agent) return;
const pres = computePresence(agent, now);
const counter = $('#zl-ecg-counter-' + agentId);
if (counter) counter.textContent = pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--';
const ecgEl = $('#zl-ecg-' + agentId);
if (ecgEl) {
ecgEl.className = `ecg ${pres.color} ${pres.bpm === 0 ? 'flat' : ''}`;
const wrapper = ecgEl.querySelector('.ecg-wrapper');
if (wrapper) wrapper.style.animationDuration = `${pres.bpm > 0 ? 72 / pres.bpm : 3.2}s`;
}
const pill = card.querySelector('.zl-pill');
if (pill) { pill.className = `zl-pill ${pres.status}`; pill.textContent = pres.status; }
card.classList.toggle('agent-stale-yellow', pres.status === 'idle' && pres.color === 'yellow');
card.classList.toggle('agent-stale-orange', pres.status === 'idle' && pres.color === 'orange');
card.classList.toggle('agent-stale-red', pres.status === 'offline');
});
updateStats();
}
function renderAgents() {
const list = $('#zl-agents-list');
if (!list) return;
const now = Date.now();
const needle = searchTerm.toLowerCase();
const deduped = dedupeAgents(Array.from(agents.values()));
const filtered = deduped.filter(a => !needle || [a.id, a.hostname, a.ip, a.os, a.mac].filter(Boolean).join(' ').toLowerCase().includes(needle));
filtered.sort((a, b) => {
const pa = computePresence(a, now), pb = computePresence(b, now);
const rank = { online: 0, idle: 1, offline: 2 };
if (rank[pa.status] !== rank[pb.status]) return rank[pa.status] - rank[pb.status];
return (a.hostname || a.id || '').localeCompare(b.hostname || b.id || '');
});
empty(list);
if (filtered.length === 0) {
list.appendChild(el('div', { class: 'zl-empty' }, [searchTerm ? t('zombie.noAgentsMatchSearch') : t('zombie.noAgentsConnected')]));
} else {
filtered.forEach(agent => list.appendChild(createAgentCard(agent, now)));
}
updateTargetSelect();
const countEl = $('#zl-agent-count');
if (countEl) {
const onlineCount = filtered.filter(a => computePresence(a, now).status === 'online').length;
countEl.textContent = `${onlineCount}/${filtered.length}`;
}
if (window.lucide) window.lucide.createIcons();
}
function createAgentCard(agent, now) {
const id = agent.id;
const pres = computePresence(agent, now);
let staleClass = pres.status === 'idle' ? ` agent-stale-${pres.color}` : (pres.status === 'offline' ? ' agent-stale-red' : '');
const isSelected = selectedAgents.has(id);
return el('div', { class: `zl-agent-card ${isSelected ? 'selected' : ''}${staleClass}`, 'data-agent-id': id }, [
el('div', { class: 'zl-card-header' }, [
el('input', { type: 'checkbox', class: 'agent-checkbox', checked: isSelected, 'data-agent-id': id }),
el('div', { class: 'zl-card-identity' }, [
el('div', { class: 'zl-card-hostname' }, [agent.hostname || 'Unknown']),
el('div', { class: 'zl-card-id' }, [id]),
]),
el('span', { class: 'zl-pill ' + pres.status }, [pres.status]),
]),
el('div', { class: 'zl-card-info' }, [
infoRow(t('common.os'), agent.os || 'Unknown'),
infoRow(t('common.ip'), agent.ip || 'N/A'),
infoRow('CPU/RAM', `${agent.cpu || 0}% / ${agent.mem || 0}%`),
]),
el('div', { class: 'zl-ecg-row' }, [
createECG(id, pres.color, pres.bpm),
el('span', { class: 'zl-ecg-counter', id: 'zl-ecg-counter-' + id }, [pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--']),
]),
el('div', { class: 'zl-card-actions' }, [
el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'shell', title: t('zombie.terminal') }, [el('i', { 'data-lucide': 'terminal' })]),
el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'browse', title: t('zombie.fileBrowser') }, [el('i', { 'data-lucide': 'folder' })]),
el('button', { class: 'btn btn-sm btn-icon btn-danger', 'data-action': 'remove', title: t('zombie.removeAgent') }, [el('i', { 'data-lucide': 'x' })]),
]),
]);
}
function createECG(id, colorClass, bpm) {
const ns = 'http://www.w3.org/2000/svg';
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', bpm > 0 ? ECG_PQRST : ECG_FLAT);
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 200 42');
svg.setAttribute('preserveAspectRatio', 'none');
svg.appendChild(path);
const wrapper = el('div', { class: 'ecg-wrapper', style: `animation-duration: ${bpm > 0 ? 72 / bpm : 3.2}s` }, [svg, svg.cloneNode(true), svg.cloneNode(true)]);
return el('div', { class: `ecg ${colorClass} ${bpm === 0 ? 'flat' : ''}`, id: 'zl-ecg-' + id }, [wrapper]);
}
function updateStats() {
const now = Date.now();
const all = Array.from(agents.values());
const onlineAgents = all.filter(a => computePresence(a, now).status === 'online');
$('#zl-stat-total').textContent = String(all.length);
$('#zl-stat-alive').textContent = String(onlineAgents.length);
const avgCPU = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.cpu || 0), 0) / onlineAgents.length) : 0;
const avgRAM = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.mem || 0), 0) / onlineAgents.length) : 0;
$('#zl-stat-avg-cpu').textContent = `${avgCPU}%`;
$('#zl-stat-avg-ram').textContent = `${avgRAM}%`;
const c2El = $('#zl-stat-c2');
if (c2El) {
c2El.textContent = c2Running ? `${t('status.online')} :${c2Port || '?'}` : t('status.offline');
c2El.className = `stat-value ${c2Running ? 'stat-online' : 'stat-offline'}`;
}
}
/* ================================================================
* Event Handlers
* ================================================================ */
function onAgentListClick(e) {
const card = e.target.closest('.zl-agent-card');
if (!card) return;
const agentId = card.dataset.agentId;
const agent = agents.get(agentId);
if (e.target.matches('.agent-checkbox')) {
if (e.target.checked) selectedAgents.add(agentId);
else selectedAgents.delete(agentId);
renderAgents();
} else if (e.target.dataset.action) {
switch (e.target.dataset.action) {
case 'shell': focusGlobalConsole(agentId); break;
case 'browse': openFileBrowser(agentId); break;
case 'remove': onRemoveAgent(agentId, agent.hostname || agentId); break;
}
}
}
function onSelectAll() {
document.querySelectorAll('.agent-checkbox').forEach(cb => {
selectedAgents.add(cb.dataset.agentId);
cb.checked = true;
});
renderAgents();
}
function onDeselectAll() {
selectedAgents.clear();
renderAgents();
}
function onSearch(e) {
searchTerm = (e.target.value || '').trim();
syncSearchClearButton();
renderAgents();
}
function clearSearch() {
const input = $('#zl-search');
if (input) input.value = '';
searchTerm = '';
renderAgents();
syncSearchClearButton();
}
function syncSearchClearButton() {
const clearBtn = $('.zl-search-clear');
if (clearBtn) clearBtn.style.display = searchTerm.length > 0 ? 'inline-block' : 'none';
}
function onRefresh() {
const wasSseHealthy = sseHealthy;
sseHealthy = false;
refreshState().finally(() => { sseHealthy = wasSseHealthy; });
toast(t('common.refreshed'));
}
async function onStartC2() {
const port = prompt(L('zombie.enterC2Port', 'Enter C2 port'), '5555');
if (!port) return;
try {
await api.post('/c2/start', { port: parseInt(port) });
toast(t('zombie.c2StartedOnPort', { port }), 2600, 'success');
await refreshState();
} catch (err) { toast(t('zombie.failedStartC2'), 2600, 'error'); }
}
async function onStopC2() {
if (!confirm(t('zombie.confirmStopC2'))) return;
try {
await api.post('/c2/stop');
toast(t('zombie.c2Stopped'), 2600, 'warning');
await refreshState();
} catch (err) { toast(t('zombie.failedStopC2'), 2600, 'error'); }
}
async function onCheckStale() {
try {
const result = await api.get('/c2/stale_agents?threshold=300');
toast(`${result.count} stale agent(s) found (>5min)`);
systemLog('info', `Stale check: ${result.count} inactive >5min.`);
} catch (err) { toast('Failed to fetch stale agents', 'error'); }
}
async function onPurgeStale() {
if (!confirm(t('zombie.confirmPurgeStale'))) return;
try {
const result = await api.post('/c2/purge_agents', { threshold: 86400 });
toast(t('zombie.agentsPurged', { count: result.purged || 0 }), 2600, 'warning');
await refreshState();
} catch (err) { toast(t('zombie.failedPurgeStale'), 2600, 'error'); }
}
function onGenerateClient() {
$('#generateModal').style.display = 'flex';
}
async function onConfirmGenerate() {
const clientId = $('#clientId').value.trim() || `zombie_${Date.now()}`;
const data = {
client_id: clientId,
platform: $('#clientPlatform').value,
lab_user: $('#labUser').value.trim(),
lab_password: $('#labPass').value.trim(),
};
try {
const result = await api.post('/c2/generate_client', data);
toast(`Client ${clientId} generated`, 'success');
if ($('#deploySSH').checked) {
await api.post('/c2/deploy', {
client_id: clientId,
ssh_host: $('#sshHost').value,
ssh_user: $('#sshUser').value,
ssh_pass: $('#sshPass').value,
lab_user: data.lab_user,
lab_password: data.lab_password,
});
toast(`Deployment to ${$('#sshHost').value} started`);
}
$('#generateModal').style.display = 'none';
if (result.filename) {
const a = el('a', { href: `/c2/download_client/${result.filename}`, download: result.filename });
a.click();
}
} catch (err) { toast(`Failed to generate: ${err.message}`, 'error'); }
}
/* ================================================================
* Console and Commands
* ================================================================ */
function consoleLog(type, message, target) {
const output = $('#zl-console-output'); if (!output) return;
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
if (typeof message === 'object') message = JSON.stringify(message, null, 2);
const line = el('div', { class: 'console-line' }, [
el('span', { class: 'console-time' }, [time]),
el('span', { class: 'console-type ' + String(type).toLowerCase() }, [type]),
target ? el('span', { class: 'console-target' }, ['[' + target + ']']) : null,
el('div', { class: 'console-content' }, [el('pre', {}, [message])]),
]);
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function systemLog(level, message) {
const output = $('#zl-logs-output'); if (!output) return;
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
output.appendChild(el('div', { class: 'zl-log-line' }, [
el('span', { class: 'console-time' }, [time]),
el('span', { class: 'console-type ' + level.toLowerCase() }, [level.toUpperCase()]),
el('div', { class: 'zl-log-text' }, [message]),
]));
output.scrollTop = output.scrollHeight;
}
function clearConsole() { empty($('#zl-console-output')); }
function clearLogs() { empty($('#zl-logs-output')); }
function onCmdKeyDown(e) {
if (e.key === 'Enter') onSendCommand();
else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) { historyIndex--; e.target.value = commandHistory[historyIndex] || ''; }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) { historyIndex++; e.target.value = commandHistory[historyIndex] || ''; }
else { historyIndex = commandHistory.length; e.target.value = ''; }
}
}
async function onSendCommand() {
const input = $('#zl-cmd-input');
const cmd = input.value.trim();
if (!cmd) return;
const target = $('#zl-target-select').value;
let targets = [];
if (target === 'broadcast') { /* targets remains empty for broadcast */ }
else if (target === 'selected') { targets = Array.from(selectedAgents); }
else { targets = [target]; }
if (target !== 'broadcast' && targets.length === 0) {
toast('No agents selected for command.', 'warning');
return;
}
await sendCommand(cmd, targets);
input.value = '';
}
async function sendCommand(command, targets = []) {
if (!command) return;
try {
const endpoint = targets.length === 0 ? '/c2/broadcast' : '/c2/command';
const payload = targets.length === 0 ? { command } : { command, targets };
consoleLog('TX', command, targets.length > 0 ? targets.join(',') : 'ALL');
await api.post(endpoint, payload);
toast(t(targets.length === 0 ? 'zombie.commandBroadcasted' : 'zombie.commandSent'), 2600, 'success');
commandHistory.push(command);
historyIndex = commandHistory.length;
} catch (err) { toast(t('zombie.failedSendCommand'), 2600, 'error'); systemLog('error', err.message); }
}
async function onRemoveAgent(agentId, name) {
if (!confirm(t('zombie.confirmRemoveAgent', { name }))) return;
try {
await api.post('/c2/remove_client', { client_id: agentId });
agents.delete(agentId); selectedAgents.delete(agentId);
renderAgents();
toast(t('zombie.agentRemoved', { name }), 2600, 'warning');
} catch (err) { toast(t('zombie.failedRemoveAgent', { name }), 2600, 'error'); }
}
/* ================================================================
* File Browser
* ================================================================ */
function openFileBrowser(agentId) {
const modal = $('#fileBrowserModal');
modal.style.display = 'flex';
modal.dataset.agentId = agentId;
$('#browserAgent').textContent = agentId;
$('#browserPath').value = '/';
browseDirectory();
}
async function browseDirectory() {
const agentId = $('#fileBrowserModal').dataset.agentId;
const path = $('#browserPath').value || '/';
const fileList = $('#fileList');
empty(fileList);
fileList.textContent = 'Loading...';
try {
await sendCommand(`ls -la ${path}`, [agentId]);
// The result will arrive via SSE and be handled by the 'console' event listener.
// For now, we assume it's coming to the main console. A better way would be a dedicated event.
// This is a limitation of the current design. We can refine it later.
toast('Browse command sent. Check console for output.');
} catch (err) {
toast('Failed to send browse command', 'error');
fileList.textContent = 'Error.';
}
}
function onUploadFile() {
const agentId = $('#fileBrowserModal').dataset.agentId;
const path = $('#browserPath').value || '/';
const input = el('input', {
type: 'file',
onchange: (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = btoa(event.target.result);
const filePath = `${path.endsWith('/') ? path : path + '/'}${file.name}`;
try {
await sendCommand(`upload ${filePath} ${base64}`, [agentId]);
toast(`File ${file.name} upload started.`);
} catch { toast('Failed to upload file.', 'error'); }
};
reader.readAsBinaryString(file);
}
});
input.click();
}
/* ================================================================
* Helpers
* ================================================================ */
function updateTargetSelect() {
const select = $('#zl-target-select');
if (!select) return;
const currentVal = select.value;
empty(select);
select.appendChild(el('option', { value: 'broadcast' }, [t('zombie.allAgents')]));
select.appendChild(el('option', { value: 'selected' }, [t('zombie.selectedAgents'), ` (${selectedAgents.size})`]));
const now = Date.now();
for (const agent of agents.values()) {
if (computePresence(agent, now).status === 'online') {
select.appendChild(el('option', { value: agent.id }, [agent.hostname || agent.id]));
}
}
select.value = currentVal; // Preserve selection if possible
}
function focusGlobalConsole(agentId) {
const sel = $('#zl-target-select');
if (sel) sel.value = agentId;
$('#zl-cmd-input')?.focus();
}
function infoRow(label, value) {
return el('div', { class: 'zl-info-row' }, [el('span', { class: 'zl-info-label' }, [label + ':']), el('span', { class: 'zl-info-value' }, [value])]);
}
function dedupeAgents(arr) {
const byHost = new Map();
arr.forEach(a => {
const key = (a.hostname || '').trim().toLowerCase() || a.id;
const prev = byHost.get(key);
if (!prev || parseTs(a.last_seen) >= parseTs(prev.last_seen)) byHost.set(key, a);
});
return Array.from(byHost.values());
}
function maxTimestamp(a, b) {
const ta = parseTs(a), tb = parseTs(b);
if (ta == null) return b; if (tb == null) return a;
return ta >= tb ? a : b;
}
function parseTs(v) {
if (v == null) return NaN;
if (typeof v === 'number') return v;
return Date.parse(v);
}