mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 00:41:59 +00:00
Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences. - Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling. - Included helper methods for querying the database and sending JSON responses. - Integrated model metadata extraction for visualization purposes.
This commit is contained in:
41
web/js/pages/_stub.js
Normal file
41
web/js/pages/_stub.js
Normal 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); }
|
||||
}
|
||||
1455
web/js/pages/actions-studio-runtime.js
Normal file
1455
web/js/pages/actions-studio-runtime.js
Normal file
File diff suppressed because it is too large
Load Diff
342
web/js/pages/actions-studio.js
Normal file
342
web/js/pages/actions-studio.js
Normal 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">☰</button>
|
||||
<button class="btn icon" id="btnIns" title="Open inspector panel" aria-controls="right">⚙</button>
|
||||
<button class="btn" id="btnAutoLayout" title="Auto-layout">⚡ 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">⋮</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">×</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">×</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">×</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">×</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">×</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
817
web/js/pages/actions.js
Normal 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
953
web/js/pages/attacks.js
Normal 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
459
web/js/pages/backup.js
Normal 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
644
web/js/pages/bjorn-debug.js
Normal 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
185
web/js/pages/bjorn.js
Normal 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
444
web/js/pages/credentials.js
Normal 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
724
web/js/pages/dashboard.js
Normal 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
499
web/js/pages/database.js
Normal 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
952
web/js/pages/files.js
Normal 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
556
web/js/pages/loot.js
Normal 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
448
web/js/pages/netkb.js
Normal 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
566
web/js/pages/network.js
Normal 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';
|
||||
}
|
||||
614
web/js/pages/rl-dashboard.js
Normal file
614
web/js/pages/rl-dashboard.js
Normal 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
544
web/js/pages/scheduler.js
Normal 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());
|
||||
}
|
||||
917
web/js/pages/vulnerabilities.js
Normal file
917
web/js/pages/vulnerabilities.js
Normal 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
801
web/js/pages/web-enum.js
Normal 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
762
web/js/pages/zombieland.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user