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

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);
}