Add Loki and Sentinel utility classes for web API endpoints

- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads.
- Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications.
- Both classes include error handling and JSON response formatting.
This commit is contained in:
infinition
2026-03-14 22:33:10 +01:00
parent eb20b168a6
commit aac77a3e76
525 changed files with 29400 additions and 13136 deletions

View File

@@ -2,6 +2,8 @@
* Actions Studio runtime for SPA mode.
* Keeps graph behavior from original studio while running inside route mount/unmount lifecycle.
*/
import { t } from '../core/i18n.js';
export function mountStudioRuntime(__root) {
const tracked = [];
const nativeAdd = EventTarget.prototype.addEventListener;
@@ -132,15 +134,15 @@ async function saveToStudio(){
state.nodes.forEach((n,id)=> data.nodes.push({id,...n}));
try{
const r = await fetch(`${API_BASE}/studio/save`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
if(!r.ok) throw 0; toast('Sauvegardé','success');
if(!r.ok) throw 0; toast(t('studio.saved'),'success');
}catch{
localStorage.setItem('bjorn_studio_backup', JSON.stringify(data));
toast('Sauvegarde locale (DB indisponible)','warn');
toast(t('studio.localBackup'),'warn');
}
}
async function applyToRuntime(){
try{ const r = await fetch(`${API_BASE}/studio/apply`,{method:'POST'}); if(!r.ok) throw 0; toast('Appliqué au runtime','success'); }
catch{ toast('Apply runtime échoué','error'); }
try{ const r = await fetch(`${API_BASE}/studio/apply`,{method:'POST'}); if(!r.ok) throw 0; toast(t('studio.applied'),'success'); }
catch{ toast(t('studio.applyFailed'),'error'); }
}
/* ===================== Helpers UI ===================== */
@@ -199,7 +201,7 @@ function buildPalette(){
if (!visibleCount) {
const empty = document.createElement('div');
empty.className = 'small';
empty.textContent = 'No actions match this filter.';
empty.textContent = t('studio.noActionsMatch');
list.appendChild(empty);
}
const total = arr.length;
@@ -237,13 +239,13 @@ function buildHostPalette(){
if (!visibleReal) {
const empty = document.createElement('div');
empty.className = 'small';
empty.textContent = 'No real hosts match this filter.';
empty.textContent = t('studio.noRealHostsMatch');
real.appendChild(empty);
}
if (!visibleTest) {
const empty = document.createElement('div');
empty.className = 'small';
empty.textContent = 'No test hosts yet.';
empty.textContent = t('studio.noTestHostsYet');
test.appendChild(empty);
}
const allHosts = [...state.hosts.values()];
@@ -848,7 +850,7 @@ function autoLayout(){
// à la fin d'autoLayout():
repelLayout(6, 0.4); // applique aussi le snap vertical des hosts
toast('Auto-layout appliqué','success');
toast(t('studio.autoLayoutApplied'),'success');
}
/* ===================== Inspectors ===================== */
@@ -1240,8 +1242,8 @@ $('#mAutoLayout')?.addEventListener('click',()=>{ $('#mainMenu').style.display='
$('#mRepel')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; repelLayout(); });
$('#mFit')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; fitToScreen(); });
$('#mHelp')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; setHelpModalOpen(true); });
$('#mImportdbActions').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Actions DB - TODO','warn'); });
$('#mImportdbActionsStudio').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Studio DB - TODO','warn'); });
$('#mImportdbActions').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast(t('studio.importActionsDb') + ' - TODO','warn'); });
$('#mImportdbActionsStudio').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast(t('studio.importStudioDb') + ' - TODO','warn'); });
$('#btnHideCanvasHint')?.addEventListener('click',()=>{
const p = loadPrefs();
savePrefsNow({ ...p, hideCanvasHint: true });
@@ -1299,7 +1301,7 @@ $('#btnUpdateAction').addEventListener('click',()=>{
const tt=$('#t_type').value, tp=$('#t_param').value.trim(); a.b_trigger=tp?`${tt}:${tp}`:tt;
const el=$(`[data-id="${state.selected}"]`); if(el){ el.className=`node ${a.b_action==='global'?'global':''}`; el.querySelector('.badge').textContent=a.b_action||'normal'; el.querySelector('.v.trigger').textContent=summTrig(a.b_trigger||''); el.querySelector('.v.requires').textContent=requireSummary(a); }
LinkEngine.render(); toast('Action mise à jour','success');
LinkEngine.render(); toast(t('studio.actionUpdated'),'success');
});
$('#btnDeleteNode').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); });
@@ -1308,7 +1310,7 @@ $('#btnUpdateHost').addEventListener('click',()=>{
h.hostname=$('#h_hostname').value.trim(); h.ips=$('#h_ips').value.trim(); h.ports=$('#h_ports').value.trim(); h.alive=parseInt($('#h_alive').value);
h.essid=$('#h_essid').value.trim(); h.services=$('#h_services').value.trim(); h.vulns=$('#h_vulns').value.trim(); h.creds=$('#h_creds').value.trim();
const el=$(`[data-id="${state.selected}"]`); if(el){ el.querySelector('.nname').textContent=h.hostname||h.ips||h.mac_address; const rows=el.querySelectorAll('.nbody .row .v'); if(rows[0]) rows[0].textContent=h.ips||'—'; if(rows[1]) rows[1].textContent=h.ports||'—'; if(rows[2]) rows[2].textContent=h.alive?'🟢':'🔴'; }
LinkEngine.render(); toast('Host mis à jour','success');
LinkEngine.render(); toast(t('studio.hostUpdated'),'success');
});
$('#btnDeleteHost').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); });
@@ -1320,16 +1322,16 @@ window.addHostToCanvas=function(mac){
else{ const rect=$('#center').getBoundingClientRect(); const x=80; const y=(rect.height/2 - state.pan.y)/state.pan.scale - 60; addHostNode(h,x,y); LinkEngine.render(); }
};
window.deleteTestHost=function(mac){
if(!confirm('Delete this test host?')) return;
state.hosts.delete(mac); const ids=[]; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) ids.push(id); }); ids.forEach(id=>deleteNode(id)); buildHostPalette(); toast('Test host supprimé','success');
if(!confirm(t('studio.deleteTestHost'))) return;
state.hosts.delete(mac); const ids=[]; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) ids.push(id); }); ids.forEach(id=>deleteNode(id)); buildHostPalette(); toast(t('studio.testHostDeleted'),'success');
};
window.openHostModal=function(){ $('#hostModal').classList.add('show'); };
window.closeHostModal=function(){ $('#hostModal').classList.remove('show'); };
window.createTestHost=function(){
const mac=$('#new_mac').value.trim() || `AA:BB:CC:${Math.random().toString(16).slice(2,8).toUpperCase()}`;
if(state.hosts.has(mac)){ toast('MAC existe déjà','error'); return; }
if(state.hosts.has(mac)){ toast(t('studio.macExists'),'error'); return; }
const host={ mac_address:mac, hostname:$('#new_hostname').value.trim()||'test-host', ips:$('#new_ips').value.trim()||'', ports:$('#new_ports').value.trim()||'', services:$('#new_services').value.trim()||'[]', vulns:$('#new_vulns').value.trim()||'', creds:$('#new_creds').value.trim()||'[]', alive:parseInt($('#new_alive').value)||1, is_simulated:1 };
state.hosts.set(mac,host); buildHostPalette(); closeHostModal(); toast('Test host créé','success'); addHostToCanvas(mac);
state.hosts.set(mac,host); buildHostPalette(); closeHostModal(); toast(t('studio.testHostCreated'),'success'); addHostToCanvas(mac);
};
$('#btnCreateHost').addEventListener('click',openHostModal);
$('#mAddHost').addEventListener('click',openHostModal);
@@ -1426,7 +1428,7 @@ async function init(){
applyPanZoom();
LinkEngine.render();
updateStats();
toast('Studio loaded','success');
toast(t('studio.saved'),'success');
}
init();

View File

@@ -13,74 +13,74 @@ function studioTemplate() {
<div id="app">
<header>
<div class="logo" aria-hidden="true"></div>
<h1>BJORN Studio</h1>
<h1>${t('studio.title')}</h1>
<div class="sp"></div>
<button class="btn icon" id="btnPal" title="Open actions/hosts panel" aria-controls="left">&#9776;</button>
<button class="btn icon" id="btnIns" title="Open inspector panel" aria-controls="right">&#9881;</button>
<button class="btn" id="btnAutoLayout" title="Auto-layout">&#9889; Auto-layout</button>
<button class="btn" id="btnRepel" title="Repel overlap">Repel</button>
<button class="btn primary" id="btnApply" title="Save and apply">Apply</button>
<button class="btn" id="btnHelp" title="Show shortcuts and gestures">Help</button>
<button class="btn icon" id="btnPal" title="${t('studio.openPalette')}" aria-controls="left">&#9776;</button>
<button class="btn icon" id="btnIns" title="${t('studio.openInspector')}" aria-controls="right">&#9881;</button>
<button class="btn" id="btnAutoLayout" title="${t('studio.autoLayout')}">&#9889; ${t('studio.autoLayout')}</button>
<button class="btn" id="btnRepel" title="${t('studio.repel')}">${t('studio.repel')}</button>
<button class="btn primary" id="btnApply" title="${t('studio.apply')}">${t('studio.apply')}</button>
<button class="btn" id="btnHelp" title="${t('studio.help')}">${t('studio.help')}</button>
<div class="kebab" style="position:relative">
<div class="kebab">
<button class="btn icon" id="btnMenu" aria-haspopup="true">&#8942;</button>
<div class="menu" id="mainMenu" role="menu" aria-label="Actions" style="position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px;box-shadow:0 10px 32px rgba(0,0,0,.45);display:none;z-index:2400">
<div class="item" id="mAddHost" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Add host</div>
<div class="item" id="mAutoLayout" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Auto layout</div>
<div class="item" id="mRepel" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Repel overlap</div>
<div class="item" id="mFit" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Fit graph</div>
<div class="item" id="mHelp" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Help</div>
<div class="item" id="mSave" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Save to DB</div>
<div class="item" id="mImportdbActions" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Import actions DB</div>
<div class="item" id="mImportdbActionsStudio" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">Import studio DB</div>
<div class="menu studio-kebab-menu" id="mainMenu" role="menu" aria-label="${t('common.actions')}">
<div class="item" id="mAddHost" role="menuitem">${t('studio.addHost')}</div>
<div class="item" id="mAutoLayout" role="menuitem">${t('studio.autoLayout')}</div>
<div class="item" id="mRepel" role="menuitem">${t('studio.repel')}</div>
<div class="item" id="mFit" role="menuitem">${t('studio.fitGraph')}</div>
<div class="item" id="mHelp" role="menuitem">${t('studio.help')}</div>
<div class="item" id="mSave" role="menuitem">${t('studio.saveToDb')}</div>
<div class="item" id="mImportdbActions" role="menuitem">${t('studio.importActionsDb')}</div>
<div class="item" id="mImportdbActionsStudio" role="menuitem">${t('studio.importStudioDb')}</div>
</div>
</div>
</header>
<main>
<aside id="left" aria-label="Palette">
<aside id="left" aria-label="${t('studio.palette')}">
<div class="studio-sidehead">
<div class="studio-sidehead-title">Palette</div>
<button class="btn icon studio-side-close" id="btnCloseLeft" type="button" aria-label="Close left panel">&times;</button>
<div class="studio-sidehead-title">${t('studio.palette')}</div>
<button class="btn icon studio-side-close" id="btnCloseLeft" type="button" aria-label="${t('studio.closePanel')}">&times;</button>
</div>
<div class="tabs">
<div class="tab active" data-tab="actions">Actions</div>
<div class="tab" data-tab="hosts">Hosts</div>
<div class="tab active" data-tab="actions">${t('studio.actionsTab')}</div>
<div class="tab" data-tab="hosts">${t('studio.hostsTab')}</div>
</div>
<div class="tab-content active" id="tab-actions">
<div class="search-row">
<input class="search" id="filterActions" placeholder="Filter actions...">
<button class="search-clear" id="clearFilterActions" aria-label="Clear action filter">&times;</button>
<input class="search" id="filterActions" placeholder="${t('studio.filterActions')}">
<button class="search-clear" id="clearFilterActions" aria-label="${t('common.clear')}">&times;</button>
</div>
<div class="palette-meta" id="actionsMeta">
<span class="pill"><span id="actionsTotalCount">0</span> total</span>
<span class="pill"><span id="actionsPlacedCount">0</span> placed</span>
<span class="pill"><span id="actionsTotalCount">0</span> ${t('studio.total')}</span>
<span class="pill"><span id="actionsPlacedCount">0</span> ${t('studio.placed')}</span>
</div>
<h2>Available actions</h2>
<h2>${t('studio.availableActions')}</h2>
<div id="plist"></div>
</div>
<div class="tab-content" id="tab-hosts">
<div class="search-row">
<input class="search" id="filterHosts" placeholder="Filter host/IP/MAC...">
<button class="search-clear" id="clearFilterHosts" aria-label="Clear host filter">&times;</button>
<input class="search" id="filterHosts" placeholder="${t('studio.filterHosts')}">
<button class="search-clear" id="clearFilterHosts" aria-label="${t('common.clear')}">&times;</button>
</div>
<div class="palette-meta" id="hostsMeta">
<span class="pill"><span id="hostsTotalCount">0</span> total</span>
<span class="pill"><span id="hostsAliveCount">0</span> alive</span>
<span class="pill"><span id="hostsPlacedCount">0</span> placed</span>
<span class="pill"><span id="hostsTotalCount">0</span> ${t('studio.total')}</span>
<span class="pill"><span id="hostsAliveCount">0</span> ${t('studio.alive')}</span>
<span class="pill"><span id="hostsPlacedCount">0</span> ${t('studio.placed')}</span>
</div>
<button class="btn" id="btnCreateHost" style="width:100%;margin-bottom:10px">Create test host</button>
<h2>Real hosts</h2>
<button class="btn studio-create-host-btn" id="btnCreateHost">${t('studio.createTestHost')}</button>
<h2>${t('studio.realHosts')}</h2>
<div id="realHosts"></div>
<h2>Test hosts</h2>
<h2>${t('studio.testHosts')}</h2>
<div id="testHosts"></div>
</div>
</aside>
<section id="center" aria-label="Canvas">
<section id="center" aria-label="${t('studio.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>
@@ -90,24 +90,24 @@ function studioTemplate() {
<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>
<button class="ctrl" id="zFit" title="${t('studio.fitGraph')}" aria-label="${t('studio.fitGraph')}">[]</button>
</div>
<div id="canvasHint" class="canvas-hint">
<strong>Tips</strong>
<span>Drag background to pan, mouse wheel/pinch to zoom, connect ports to link nodes.</span>
<button id="btnHideCanvasHint" class="btn icon" aria-label="Hide hint">&times;</button>
<strong>${t('studio.tips')}</strong>
<span>${t('studio.tipsText')}</span>
<button id="btnHideCanvasHint" class="btn icon" aria-label="${t('common.hide')}">&times;</button>
</div>
</section>
<aside id="right" aria-label="Inspector">
<aside id="right" aria-label="${t('studio.inspector')}">
<div class="studio-sidehead">
<div class="studio-sidehead-title">Inspector</div>
<button class="btn icon studio-side-close" id="btnCloseRight" type="button" aria-label="Close right panel">&times;</button>
<div class="studio-sidehead-title">${t('studio.inspector')}</div>
<button class="btn icon studio-side-close" id="btnCloseRight" type="button" aria-label="${t('studio.closePanel')}">&times;</button>
</div>
<div class="section" id="actionInspector">
<h3>Selected action</h3>
<div id="noSel" class="small">Select a node to edit it</div>
<h3>${t('studio.selectedAction')}</h3>
<div id="noSel" class="small">${t('studio.selectNodeToEdit')}</div>
<div id="edit" style="display:none">
<label><span>b_class</span><input id="e_class" disabled></label>
<div class="form-row">
@@ -115,15 +115,15 @@ function studioTemplate() {
<label><span>b_status</span><input id="e_status"></label>
</div>
<div class="form-row">
<label><span>Type</span>
<label><span>${t('common.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><span>${t('common.enabled')}</span>
<select id="e_enabled"><option value="1">${t('common.yes')}</option><option value="0">${t('common.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>${t('sched.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">
@@ -132,14 +132,14 @@ function studioTemplate() {
</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>
<label><span>${t('common.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>
<h3>${t('studio.trigger')}</h3>
<div class="form-row">
<label><span>Type</span>
<label><span>${t('common.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>
@@ -148,107 +148,107 @@ function studioTemplate() {
<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>
<label><span>Parameter</span><input id="t_param" placeholder="port / service / ActionName / JSON list" class="mono-input"></label>
</div>
<hr>
<h3>Requirements</h3>
<h3>${t('studio.requirement')}</h3>
<div class="row">
<label style="flex:1"><span>Mode</span>
<label class="flex-1"><span>${t('studio.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>
<button class="btn" id="r_add">${t('studio.addCondition')}</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 class="row studio-action-btns">
<button class="btn primary" id="btnUpdateAction">${t('studio.apply')}</button>
<button class="btn" id="btnDeleteNode">${t('studio.removeFromCanvas')}</button>
</div>
</div>
</div>
<div class="section" id="hostInspector" style="display:none">
<h3>Selected host</h3>
<h3>${t('studio.selectedHost')}</h3>
<div class="form-row">
<label><span>MAC</span><input id="h_mac"></label>
<label><span>Hostname</span><input id="h_hostname"></label>
<label><span>${t('common.mac')}</span><input id="h_mac"></label>
<label><span>${t('common.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>
<label><span>${t('common.ip')}(s)</span><input id="h_ips" placeholder="192.168.1.10;192.168.1.11"></label>
<label><span>${t('common.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>
<select id="h_alive"><option value="1">${t('common.yes')}</option><option value="0">${t('common.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 class="row studio-action-btns">
<button class="btn primary" id="btnUpdateHost">${t('studio.apply')}</button>
<button class="btn" id="btnDeleteHost">${t('studio.deleteFromCanvas')}</button>
</div>
</div>
</aside>
<button id="sideBackdrop" class="studio-side-backdrop" aria-hidden="true" aria-label="Close side panels"></button>
<button id="sideBackdrop" class="studio-side-backdrop" aria-hidden="true" aria-label="${t('studio.closePanel')}"></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>
<button class="btn" id="btnPalDock" aria-controls="left" title="${t('studio.openPalette')}">${t('studio.palette')}</button>
<button class="btn" id="btnFitDock" title="${t('studio.fitGraph')}">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>
<button class="btn primary" id="btnApplyDock">${t('studio.apply')}</button>
<button class="btn" id="btnInsDock" aria-controls="right" title="${t('studio.openInspector')}">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>
<div class="pill"><span class="legend-dot legend-ok"></span> ${t('studio.success')}</div>
<div class="pill"><span class="legend-dot legend-bad"></span> ${t('studio.failure')}</div>
<div class="pill"><span class="legend-dot legend-req"></span> ${t('studio.requires')}</div>
<div class="pill">${t('studio.pinchHint')}</div>
<div class="pill"><span id="nodeCount">0</span> ${t('studio.nodesCount')}, <span id="linkCount">0</span> ${t('studio.linksCount')}</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 class="edge-menu-item" data-action="edit">${t('common.edit')}...</div>
<div class="edge-menu-item" data-action="toggle-success">${t('studio.success')}</div>
<div class="edge-menu-item" data-action="toggle-failure">${t('studio.failure')}</div>
<div class="edge-menu-item" data-action="toggle-req">${t('studio.requires')}</div>
<div class="edge-menu-item danger" data-action="delete">${t('common.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>
<h2 class="modal-title" id="linkWizardTitle">${t('studio.link')}</h2>
<button class="modal-close" id="lwClose" aria-label="${t('common.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 class="row studio-link-endpoints">
<div class="pill">${t('studio.from')}: <b id="lwFromName">-</b></div>
<div class="pill">${t('studio.to')}: <b id="lwToName">-</b></div>
</div>
<p class="small" id="lwContext">Choose behavior (trigger or requirement). Presets adapt to node types.</p>
<p class="small" id="lwContext">${t('studio.linkContext')}</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><span>${t('studio.mode')}</span>
<select id="lwMode"><option value="trigger">${t('studio.trigger')}</option><option value="requires">${t('studio.requirement')}</option></select>
</label>
<label><span>Preset</span><select id="lwPreset"></select></label>
<label><span>${t('studio.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>
<label><span>${t('studio.param1')}</span><input id="lwParam1" placeholder="ssh / 22 / CVE-..."></label>
<label><span>${t('studio.param2')}</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 class="section studio-preview-row">
<div class="row"><div class="pill">${t('studio.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 class="row studio-wizard-btns">
<button class="btn primary" id="lwCreate">${t('studio.validate')}</button>
<button class="btn" id="lwCancel">${t('common.cancel')}</button>
</div>
</div>
</div>
@@ -257,27 +257,27 @@ function studioTemplate() {
<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>
<h2 class="modal-title" id="hostModalTitle">${t('studio.addTestHost')}</h2>
<button class="modal-close" onclick="closeHostModal()" aria-label="${t('common.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>${t('common.mac')}</span><input id="new_mac" placeholder="AA:BB:CC:DD:EE:FF"></label>
<label><span>${t('common.hostname')}</span><input id="new_hostname" placeholder="test-server-01"></label>
<label><span>${t('common.ip')}(s)</span><input id="new_ips" placeholder="192.168.1.100;192.168.1.101"></label>
<label><span>${t('common.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>
<label><span>Vulns (CSV)</span><input id="new_vulns" placeholder="CVE-2023-1234, CVE-2024-5678"></label>
<label><span>Creds (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>
<select id="new_alive"><option value="1">${t('common.yes')}</option><option value="0">${t('common.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 class="row studio-wizard-btns">
<button class="btn primary" onclick="createTestHost()">${t('studio.createTestHost')}</button>
<button class="btn" onclick="closeHostModal()">${t('common.cancel')}</button>
</div>
</div>
</div>
@@ -286,22 +286,22 @@ function studioTemplate() {
<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>
<h2 class="modal-title" id="helpModalTitle">${t('studio.shortcuts')}</h2>
<button class="modal-close" id="helpClose" aria-label="${t('common.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>
<h3>${t('studio.navigation')}</h3>
<div class="small">${t('studio.shortcutZoom')}</div>
<div class="small">${t('studio.shortcutPan')}</div>
<div class="small">${t('studio.shortcutDragNode')}</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>
<h3>${t('studio.keyboard')}</h3>
<div class="small"><b>F</b>: ${t('studio.shortcutFit')}</div>
<div class="small"><b>Ctrl/Cmd + S</b>: ${t('studio.shortcutSave')}</div>
<div class="small"><b>Esc</b>: ${t('studio.shortcutEsc')}</div>
<div class="small"><b>Delete</b>: ${t('studio.shortcutDelete')}</div>
</div>
</div>
</div>

View File

@@ -26,11 +26,6 @@ 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;
}
@@ -45,7 +40,7 @@ export async function mount(container) {
sidebarSelector: '.al-sidebar',
mainSelector: '#actionsLauncher',
storageKey: 'sidebar:actions',
toggleLabel: tx('common.menu', 'Menu'),
toggleLabel: t('common.menu'),
});
bindStaticEvents();
@@ -62,6 +57,9 @@ export function unmount() {
sidebarLayoutCleanup = null;
}
clearTimeout(onResizeDebounced._t);
onResizeDebounced._t = null;
for (const tmr of pollingTimers.values()) clearTimeout(tmr);
pollingTimers.clear();
@@ -83,14 +81,14 @@ export function unmount() {
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')]),
el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [t('actions.tabs.actions')]),
el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [t('actions.tabs.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')]),
el('div', { class: 'sidetitle' }, [t('actions.title')]),
el('button', { class: 'al-btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]),
]),
sideTabs,
el('div', { class: 'al-search' }, [
@@ -98,7 +96,7 @@ function buildShell() {
id: 'searchInput',
class: 'al-input',
type: 'text',
placeholder: tx('actions.searchPlaceholder', 'Search actions...'),
placeholder: t('actions.searchPlaceholder'),
}),
]),
]);
@@ -109,8 +107,8 @@ function buildShell() {
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', { class: 'h' }, [t('actions.args.title')]),
el('div', { class: 'sub' }, [t('actions.args.subtitle')]),
]),
el('div', { id: 'argBuilder', class: 'builder' }),
el('div', { class: 'section' }, [
@@ -118,7 +116,7 @@ function buildShell() {
id: 'freeArgs',
class: 'ctl',
type: 'text',
placeholder: tx('actions.args.free', 'Additional arguments (e.g., --verbose --debug)'),
placeholder: t('actions.args.free'),
}),
]),
el('div', { id: 'presetChips', class: 'chips' }),
@@ -242,7 +240,7 @@ async function loadActions() {
empty(q('#presetChips'));
}
} catch (err) {
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
toast(`${t('common.error')}: ${err.message}`, 2600, 'error');
actions = [];
}
}
@@ -267,7 +265,7 @@ function normalizeAction(raw) {
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'),
description: raw.description || t('common.description'),
args,
icon: raw.b_icon || `/actions_icons/${encodeURIComponent(raw.b_class || id)}.png`,
version: raw.b_version || '',
@@ -292,7 +290,7 @@ function renderActionsList() {
});
if (!filtered.length) {
container.appendChild(el('div', { class: 'sub' }, [tx('actions.noActions', 'No actions found')]));
container.appendChild(el('div', { class: 'sub' }, [t('actions.noActions')]));
return;
}
@@ -333,10 +331,10 @@ function statusChipClass(status) {
}
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');
if (status === 'running') return t('actions.running');
if (status === 'success') return t('common.success');
if (status === 'error') return t('common.error');
return t('common.ready');
}
function onActionSelected(actionId) {
@@ -378,13 +376,13 @@ function renderArguments(action) {
const metaBits = [];
if (action.version) metaBits.push(`v${action.version}`);
if (action.author) metaBits.push(`by ${action.author}`);
if (action.author) metaBits.push(t('actions.byAuthor', { author: 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'])
? el('a', { class: 'al-btn', href: action.docsUrl, target: '_blank', rel: 'noopener noreferrer' }, [t('actions.docs')])
: null,
]);
builder.appendChild(top);
@@ -392,7 +390,7 @@ function renderArguments(action) {
const entries = Object.entries(action.args || {});
if (!entries.length) {
builder.appendChild(el('div', { class: 'sub' }, [tx('actions.args.none', 'No configurable arguments')]));
builder.appendChild(el('div', { class: 'sub' }, [t('actions.args.none')]));
}
for (const [key, cfgRaw] of entries) {
@@ -409,7 +407,7 @@ function renderArguments(action) {
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 label = p.name || p.title || t('actions.preset', { n: i + 1 });
const btn = el('button', { class: 'chip2', type: 'button' }, [label]);
tracker.trackEventListener(btn, 'click', () => applyPreset(p));
chips.appendChild(btn);
@@ -493,7 +491,7 @@ function applyPreset(preset) {
else input.value = String(v ?? '');
}
toast(tx('actions.toast.presetApplied', 'Preset applied'), 1400, 'success');
toast(t('actions.toast.presetApplied'), 1400, 'success');
}
function collectArguments() {
@@ -551,33 +549,33 @@ function renderConsoles() {
},
}) : null,
el('div', { class: 'titleBlock' }, [
el('div', { class: 'titleLine' }, [el('strong', {}, [action ? action.name : tx('actions.emptyPane', '— Empty Pane —')])]),
el('div', { class: 'titleLine' }, [el('strong', {}, [action ? action.name : t('actions.emptyPane')])]),
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,
action.author ? el('span', { class: 'chip' }, [t('actions.byAuthor', { author: 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')]);
const assignBtn = el('button', { class: 'al-btn', type: 'button' }, [t('actions.assign')]);
tracker.trackEventListener(assignBtn, 'click', () => setAssignTarget(i));
paneBtns.appendChild(assignBtn);
} else {
const runBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('common.run', 'Run')]);
const runBtn = el('button', { class: 'al-btn', type: 'button' }, [t('common.run')]);
tracker.trackEventListener(runBtn, 'click', () => runActionInPane(i));
const stopBtn = el('button', { class: 'al-btn warn', type: 'button' }, [tx('common.stop', 'Stop')]);
const stopBtn = el('button', { class: 'al-btn warn', type: 'button' }, [t('common.stop')]);
tracker.trackEventListener(stopBtn, 'click', () => stopActionInPane(i));
const clearBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('console.clear', 'Clear')]);
const clearBtn = el('button', { class: 'al-btn', type: 'button' }, [t('common.clear')]);
tracker.trackEventListener(clearBtn, 'click', () => clearActionLogs(action.id));
const exportBtn = el('button', { class: 'al-btn', type: 'button' }, ['⬇ Export']);
const exportBtn = el('button', { class: 'al-btn', type: 'button' }, ['\u2B07 ' + t('actions.exportLogs')]);
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']);
const autoBtn = el('button', { class: 'al-btn', type: 'button' }, [autoClearPane[i] ? t('actions.autoClearOn') : t('actions.autoClearOff')]);
if (autoClearPane[i]) autoBtn.classList.add('warn');
tracker.trackEventListener(autoBtn, 'click', () => {
autoClearPane[i] = !autoClearPane[i];
@@ -622,13 +620,13 @@ function renderPaneLog(index, actionId) {
empty(logEl);
if (!actionId) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.empty', 'Select an action to see logs')]));
logEl.appendChild(el('div', { class: 'logline dim' }, [t('actions.selectAction')]));
return;
}
const lines = logsByAction.get(actionId) || [];
if (!lines.length) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.waiting', 'Waiting for logs...')]));
logEl.appendChild(el('div', { class: 'logline dim' }, [t('actions.waitingLogs')]));
return;
}
@@ -671,14 +669,15 @@ function highlightPane(index) {
const pane = q(`.pane[data-index="${index}"]`);
if (!pane) return;
pane.classList.add('paneHighlight');
setTimeout(() => pane.classList.remove('paneHighlight'), 900);
if (tracker) tracker.trackTimeout(() => pane.classList.remove('paneHighlight'), 900);
else 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');
toast(t('actions.toast.selectActionFirst'), 1600, 'warning');
return;
}
@@ -690,7 +689,7 @@ async function runActionInPane(index) {
renderConsoles();
const args = collectArguments();
appendActionLog(action.id, tx('actions.toast.startingAction', 'Starting {{name}}...').replace('{{name}}', action.name));
appendActionLog(action.id, t('actions.toast.startingAction', { name: action.name }));
try {
const res = await api.post('/run_script', { script_name: action.module || action.id, args });
@@ -701,7 +700,7 @@ async function runActionInPane(index) {
appendActionLog(action.id, `Error: ${err.message}`);
renderActionsList();
renderConsoles();
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
toast(`${t('common.error')}: ${err.message}`, 2600, 'error');
}
}
@@ -716,11 +715,11 @@ async function stopActionInPane(index) {
action.status = 'ready';
stopOutputPolling(action.id);
appendActionLog(action.id, tx('actions.toast.stoppedByUser', 'Stopped by user'));
appendActionLog(action.id, t('actions.toast.stoppedByUser'));
renderActionsList();
renderConsoles();
} catch (err) {
toast(`${tx('actions.toast.failedToStop', 'Failed to stop')}: ${err.message}`, 2600, 'error');
toast(`${t('actions.toast.failedToStop')}: ${err.message}`, 2600, 'error');
}
}
@@ -737,7 +736,7 @@ function clearActionLogs(actionId) {
function exportActionLogs(actionId, actionName = 'action') {
const logs = logsByAction.get(actionId) || [];
if (!logs.length) {
toast(tx('actions.toast.noLogsToExport', 'No logs to export'), 1600, 'warning');
toast(t('actions.toast.noLogsToExport'), 1600, 'warning');
return;
}
@@ -790,7 +789,7 @@ function startOutputPolling(actionId) {
appendActionLog(actionId, `Error: ${data.last_error}`);
} else {
action.status = 'success';
appendActionLog(actionId, tx('actions.logs.completed', 'Script completed'));
appendActionLog(actionId, t('actions.logs.completed'));
}
stopOutputPolling(actionId);

View File

@@ -2,6 +2,7 @@ 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';
import * as epdEditor from '../core/epd-editor.js';
const PAGE = 'attacks';
let tracker = null;
@@ -40,6 +41,7 @@ function markup() {
<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>
<button class="tab-btn" data-page="epd-layout">${Lx('attacks.tabs.epdLayout', 'EPD Layout')}</button>
</div>
<div id="attacks-sidebar" class="sidebar-page" style="display:block">
@@ -80,6 +82,8 @@ function markup() {
<h3 style="margin:8px 0">${L('attacks.section.actionIcons')}</h3>
<ul class="unified-list" id="actions-icons-list"></ul>
</div>
<div id="epd-layout-sidebar" class="sidebar-page" style="display:none"></div>
</div>
<div class="attacks-main">
@@ -132,13 +136,17 @@ function markup() {
</div>
<div class="image-container" id="image-container"></div>
</div>
<div id="epd-layout-page" class="page-content"></div>
</div>`;
}
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
const data = await r.json();
if (!tracker) throw new Error('unmounted');
return data;
}
async function postJSON(url, body = {}) {
@@ -591,6 +599,7 @@ function bindTabs() {
if (page === 'attacks') await loadAttacks();
if (page === 'comments') await loadSections();
if (page === 'images') await Promise.all([loadImageScopes(), loadCharacters()]);
if (page === 'epd-layout') await epdEditor.activate(q('#epd-layout-sidebar'), q('#epd-layout-page'));
}));
}
@@ -908,6 +917,7 @@ export async function mount(container) {
bindTabs();
bindActions();
syncImageModeClasses();
epdEditor.mount(tracker);
const density = q('#density');
if (density) {
@@ -936,11 +946,19 @@ export async function mount(container) {
}
export function unmount() {
epdEditor.unmount();
for (const v of iconCache.values()) {
if (typeof v === 'string' && v.startsWith('blob:')) URL.revokeObjectURL(v);
}
iconCache.clear();
selectedImages.clear();
currentAttack = null;
selectedSection = null;
selectedImageScope = null;
selectedActionName = null;
editMode = false;
imageCache = [];
imageResolver = null;
if (disposeSidebarLayout) {
disposeSidebarLayout();
disposeSidebarLayout = null;

528
web/js/pages/bifrost.js Normal file
View File

@@ -0,0 +1,528 @@
/**
* Bifrost — Pwnagotchi Mode SPA page
* Real-time WiFi recon dashboard with face, mood, activity feed, networks, plugins.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast, escapeHtml, confirmT } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'bifrost';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let bifrostEnabled = false;
let status = {};
let stats = {};
let networks = [];
let activity = [];
let plugins = [];
let epochs = [];
let sideTab = 'networks'; // 'networks' | 'plugins' | 'history'
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refresh, 4000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
networks = [];
activity = [];
plugins = [];
epochs = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'bifrost-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'bifrost-header' }, [
el('h1', { class: 'bifrost-title' }, [
el('span', { class: 'bifrost-title-icon' }, ['🌈']),
el('span', { 'data-i18n': 'bifrost.title' }, [t('bifrost.title')]),
]),
el('div', { class: 'bifrost-controls' }, [
el('button', { class: 'bifrost-btn', id: 'bifrost-toggle' }, [
el('span', { class: 'dot' }),
el('span', { class: 'bifrost-toggle-label', 'data-i18n': 'bifrost.disabled' }, [t('bifrost.disabled')]),
]),
el('button', { class: 'bifrost-btn', id: 'bifrost-mode' }, [
el('span', { class: 'bifrost-mode-label' }, ['Auto']),
]),
]),
]),
/* ── Stats bar ────────────────────────────────────── */
el('div', { class: 'bifrost-stats', id: 'bifrost-stats' }),
/* ── Main grid ────────────────────────────────────── */
el('div', { class: 'bifrost-grid' }, [
/* Left: Live view */
el('div', { class: 'bifrost-panel bifrost-live' }, [
el('div', { class: 'bifrost-face-wrap' }, [
el('div', { class: 'bifrost-face', id: 'bifrost-face' }, ['(. .)']),
el('div', { class: 'bifrost-mood', id: 'bifrost-mood' }, ['sleeping']),
el('div', { class: 'bifrost-voice', id: 'bifrost-voice' }),
]),
el('div', { class: 'bifrost-info-row', id: 'bifrost-info' }),
el('div', { class: 'bifrost-panel-head' }, [
el('span', { 'data-i18n': 'bifrost.activityFeed' }, [t('bifrost.activityFeed')]),
el('button', {
class: 'bifrost-btn', id: 'bifrost-clear-activity',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('bifrost.clearActivity')]),
]),
el('div', { class: 'bifrost-activity', id: 'bifrost-activity' }),
]),
/* Right: sidebar */
el('div', { class: 'bifrost-panel' }, [
el('div', { class: 'bifrost-side-tabs' }, [
sideTabBtn('networks', t('bifrost.networks')),
sideTabBtn('plugins', t('bifrost.plugins')),
sideTabBtn('history', t('bifrost.history')),
]),
el('div', { class: 'bifrost-sidebar', id: 'bifrost-sidebar' }),
]),
]),
]);
}
function sideTabBtn(id, label) {
return el('button', {
class: `bifrost-side-tab${sideTab === id ? ' active' : ''}`,
'data-btab': id,
}, [label]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
root.addEventListener('click', async (e) => {
// Toggle enable/disable — BIFROST is a 4th exclusive mode
const toggle = e.target.closest('#bifrost-toggle');
if (toggle) {
const willEnable = !bifrostEnabled;
// Warn user: enabling puts WiFi in monitor mode, kills network
if (willEnable && !confirmT(t('bifrost.confirmEnable'))) return;
try {
const res = await api.post('/api/bifrost/toggle', { enabled: willEnable });
bifrostEnabled = res.enabled;
paintToggle();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Toggle mode
const modeBtn = e.target.closest('#bifrost-mode');
if (modeBtn) {
const newMode = status.mode === 'auto' ? 'manual' : 'auto';
try {
await api.post('/api/bifrost/mode', { mode: newMode });
status.mode = newMode;
paintMode();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Clear activity
if (e.target.closest('#bifrost-clear-activity')) {
try {
await api.post('/api/bifrost/activity/clear', {});
toast(t('bifrost.activityCleared'), 2000, 'success');
activity = [];
paintActivity();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Side tab switch
const stab = e.target.closest('[data-btab]');
if (stab) {
sideTab = stab.dataset.btab;
$$('.bifrost-side-tab', root).forEach(b =>
b.classList.toggle('active', b.dataset.btab === sideTab));
paintSidebar();
return;
}
// Plugin toggle
const pluginToggle = e.target.closest('[data-plugin-toggle]');
if (pluginToggle) {
const name = pluginToggle.dataset.pluginToggle;
const plugin = plugins.find(p => p.name === name);
if (plugin) {
try {
await api.post('/api/bifrost/plugin/toggle', { name, enabled: !plugin.enabled });
await refreshPlugins();
} catch (err) { toast(err.message, 3000, 'error'); }
}
return;
}
});
}
/* ── Data refresh ──────────────────────────────────────── */
async function refresh() {
try {
const [statusData, statsData, actData] = await Promise.all([
api.get('/api/bifrost/status'),
api.get('/api/bifrost/stats'),
api.get('/api/bifrost/activity?limit=50'),
]);
status = statusData || {};
stats = statsData || {};
bifrostEnabled = status.enabled || false;
activity = actData.activity || [];
paint();
} catch (err) {
console.warn('[bifrost] refresh error:', err.message);
}
// Lazy-load sidebar data
if (sideTab === 'networks') refreshNetworks();
else if (sideTab === 'plugins') refreshPlugins();
else if (sideTab === 'history') refreshEpochs();
}
async function refreshNetworks() {
try {
const data = await api.get('/api/bifrost/networks');
networks = data.networks || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] networks error:', err.message); }
}
async function refreshPlugins() {
try {
const data = await api.get('/api/bifrost/plugins');
plugins = data.plugins || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] plugins error:', err.message); }
}
async function refreshEpochs() {
try {
const data = await api.get('/api/bifrost/epochs');
epochs = data.epochs || [];
paintSidebar();
} catch (err) { console.warn('[bifrost] epochs error:', err.message); }
}
/* ── Paint ─────────────────────────────────────────────── */
function paint() {
paintToggle();
paintMode();
paintStats();
paintFace();
paintInfo();
paintActivity();
paintSidebar();
}
function paintToggle() {
const btn = $('#bifrost-toggle', root);
if (!btn) return;
btn.classList.toggle('active', bifrostEnabled);
const lbl = $('.bifrost-toggle-label', btn);
if (lbl) {
const key = bifrostEnabled ? 'bifrost.enabled' : 'bifrost.disabled';
lbl.textContent = t(key);
lbl.setAttribute('data-i18n', key);
}
}
function paintMode() {
const lbl = $('.bifrost-mode-label', root);
if (lbl) {
const mode = status.mode || 'auto';
lbl.textContent = mode === 'auto' ? 'Auto' : 'Manual';
}
}
function paintStats() {
const container = $('#bifrost-stats', root);
if (!container) return;
const items = [
{ val: stats.total_networks || 0, lbl: t('bifrost.statNetworks') },
{ val: stats.total_handshakes || 0, lbl: t('bifrost.statHandshakes') },
{ val: stats.total_deauths || 0, lbl: t('bifrost.statDeauths') },
{ val: stats.total_assocs || 0, lbl: t('bifrost.statAssocs') },
{ val: stats.total_epochs || 0, lbl: t('bifrost.statEpochs') },
{ val: stats.total_peers || 0, lbl: t('bifrost.statPeers') },
];
empty(container);
for (const s of items) {
container.appendChild(
el('div', { class: 'bifrost-stat' }, [
el('div', { class: 'bifrost-stat-val' }, [String(s.val)]),
el('div', { class: 'bifrost-stat-lbl' }, [s.lbl]),
])
);
}
}
function paintFace() {
const faceEl = $('#bifrost-face', root);
const moodEl = $('#bifrost-mood', root);
const voiceEl = $('#bifrost-voice', root);
if (faceEl) {
if (status.monitor_failed) {
faceEl.textContent = '(X_X)';
faceEl.className = 'bifrost-face mood-angry';
} else {
faceEl.textContent = status.face || '(. .)';
faceEl.className = 'bifrost-face';
if (status.mood) faceEl.classList.add('mood-' + status.mood);
}
}
if (moodEl) {
if (status.monitor_failed) {
moodEl.textContent = t('bifrost.monitorFailed');
moodEl.className = 'bifrost-mood mood-badge-angry';
} else {
const mood = status.mood || 'sleeping';
moodEl.textContent = mood;
moodEl.className = 'bifrost-mood mood-badge-' + mood;
}
}
if (voiceEl) {
if (status.monitor_failed) {
voiceEl.textContent = t('bifrost.monitorFailedHint');
} else {
voiceEl.textContent = status.voice || '';
}
}
}
function paintInfo() {
const container = $('#bifrost-info', root);
if (!container) return;
empty(container);
const items = [
{ label: 'Ch', value: status.channel || 0 },
{ label: 'APs', value: status.num_aps || 0 },
{ label: '🤝', value: status.num_handshakes || 0 },
{ label: '⏱', value: formatUptime(status.uptime || 0) },
{ label: 'Ep', value: status.epoch || 0 },
];
for (const item of items) {
container.appendChild(
el('span', { class: 'bifrost-info-chip' }, [
el('span', { class: 'bifrost-info-label' }, [item.label]),
el('span', { class: 'bifrost-info-value' }, [String(item.value)]),
])
);
}
if (status.last_pwnd) {
container.appendChild(
el('span', { class: 'bifrost-info-chip pwnd' }, [
el('span', { class: 'bifrost-info-label' }, ['🏆']),
el('span', { class: 'bifrost-info-value' }, [escapeHtml(status.last_pwnd)]),
])
);
}
}
function paintActivity() {
const container = $('#bifrost-activity', root);
if (!container) return;
empty(container);
if (activity.length === 0) {
container.appendChild(
el('div', { class: 'bifrost-empty' }, [t('bifrost.noActivity')])
);
return;
}
for (const ev of activity) {
const icon = eventIcon(ev.event_type);
container.appendChild(
el('div', { class: 'bifrost-activity-item' }, [
el('span', { class: 'bifrost-act-time' }, [formatTime(ev.timestamp)]),
el('span', { class: 'bifrost-act-icon' }, [icon]),
el('span', { class: 'bifrost-act-title' }, [escapeHtml(ev.title || '')]),
ev.details ? el('span', { class: 'bifrost-act-detail' }, [escapeHtml(ev.details)]) : '',
].filter(Boolean))
);
}
}
/* ── Sidebar panels ────────────────────────────────────── */
function paintSidebar() {
const container = $('#bifrost-sidebar', root);
if (!container) return;
empty(container);
switch (sideTab) {
case 'networks': paintNetworks(container); break;
case 'plugins': paintPlugins(container); break;
case 'history': paintEpochs(container); break;
}
}
/* ── Networks ─────────────────────────────────────────── */
function paintNetworks(container) {
if (networks.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noNetworks')]));
return;
}
for (const net of networks) {
const signal = signalBars(net.rssi);
const encBadge = encryptionBadge(net.encryption || 'OPEN');
container.appendChild(
el('div', { class: 'bifrost-net-row' }, [
el('div', { class: 'bifrost-net-main' }, [
el('span', { class: 'bifrost-net-signal' }, [signal]),
el('span', { class: 'bifrost-net-essid' }, [escapeHtml(net.essid || '<hidden>')]),
el('span', { class: 'bifrost-net-enc' }, [encBadge]),
]),
el('div', { class: 'bifrost-net-meta' }, [
`ch${net.channel || '?'}`,
net.rssi ? ` ${net.rssi}dB` : '',
net.clients ? ` · ${net.clients} sta` : '',
net.handshake ? ' · ✅' : '',
].join('')),
])
);
}
}
/* ── Plugins ──────────────────────────────────────────── */
function paintPlugins(container) {
if (plugins.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noPlugins')]));
return;
}
for (const plug of plugins) {
container.appendChild(
el('div', { class: 'bifrost-plugin-row' }, [
el('div', { class: 'bifrost-plugin-info' }, [
el('span', { class: 'bifrost-plugin-name' }, [escapeHtml(plug.name)]),
el('span', { class: 'bifrost-plugin-desc' }, [escapeHtml(plug.description || '')]),
]),
el('button', {
class: `bifrost-btn${plug.enabled ? ' active' : ''}`,
'data-plugin-toggle': plug.name,
style: 'padding:2px 8px;font-size:0.6rem',
}, [plug.enabled ? '⏸' : '▶']),
])
);
}
}
/* ── Epoch History ────────────────────────────────────── */
function paintEpochs(container) {
if (epochs.length === 0) {
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noEpochs')]));
return;
}
const table = el('div', { class: 'bifrost-epoch-table' }, [
el('div', { class: 'bifrost-epoch-header' }, [
el('span', {}, ['#']),
el('span', {}, ['🤝']),
el('span', {}, ['💀']),
el('span', {}, ['📡']),
el('span', {}, [t('bifrost.mood')]),
el('span', {}, ['⭐']),
]),
]);
for (const ep of epochs.slice(0, 50)) {
const rewardStr = typeof ep.reward === 'number' ? ep.reward.toFixed(2) : '—';
table.appendChild(
el('div', { class: 'bifrost-epoch-row' }, [
el('span', {}, [String(ep.epoch_num ?? ep.id ?? '?')]),
el('span', {}, [String(ep.num_handshakes ?? 0)]),
el('span', {}, [String(ep.num_deauths ?? 0)]),
el('span', {}, [String(ep.num_hops ?? 0)]),
el('span', { class: `mood-badge-${ep.mood || 'sleeping'}` }, [ep.mood || '—']),
el('span', {}, [rewardStr]),
])
);
}
container.appendChild(table);
}
/* ── Helpers ───────────────────────────────────────────── */
function eventIcon(type) {
const icons = {
handshake: '🤝', deauth: '💀', association: '📡',
new_ap: '📶', channel_hop: '📻', epoch: '🔄',
plugin: '🧩', error: '❌', start: '▶️', stop: '⏹️',
};
return icons[type] || '📝';
}
function signalBars(rssi) {
if (!rssi) return '▂';
const val = Math.abs(rssi);
if (val < 50) return '▂▄▆█';
if (val < 60) return '▂▄▆';
if (val < 70) return '▂▄';
return '▂';
}
function encryptionBadge(enc) {
if (!enc || enc === 'OPEN' || enc === '') return 'OPEN';
if (enc.includes('WPA3')) return 'WPA3';
if (enc.includes('WPA2')) return 'WPA2';
if (enc.includes('WPA')) return 'WPA';
if (enc.includes('WEP')) return 'WEP';
return enc;
}
function formatUptime(secs) {
if (!secs || secs < 0) return '0s';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`;
return `${m}m`;
}
function formatTime(ts) {
if (!ts) return '—';
try {
const d = new Date(ts.includes('Z') || ts.includes('+') ? ts : ts + 'Z');
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60000) return t('bifrost.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch { return ts; }
}

View File

@@ -118,20 +118,20 @@ function buildLayout() {
const controls = el('div', { class: 'dbg-controls' });
const pauseBtn = el('button', { class: 'btn dbg-btn', id: 'dbgPause' }, ['Pause']);
pauseBtn.addEventListener('click', () => {
tracker.trackEventListener(pauseBtn, '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 () => {
tracker.trackEventListener(gcBtn, '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 () => {
tracker.trackEventListener(tmBtn, 'click', async () => {
const tracing = latestSnapshot?.tracemalloc_active;
try {
const r = await api.post('/api/debug/tracemalloc', { action: tracing ? 'stop' : 'start' });

View File

@@ -31,10 +31,12 @@ export async function mount(container) {
// Fetch the configured refresh delay
try {
const data = await api.get('/get_web_delay', { timeout: 5000, retries: 1 });
if (!tracker) return; /* unmounted while awaiting */
if (data && typeof data.web_delay === 'number' && data.web_delay > 0) {
delay = data.web_delay;
}
} catch (err) {
if (!tracker) return; /* unmounted while awaiting */
console.warn(`[${PAGE}] Failed to fetch web_delay, using default ${DEFAULT_DELAY}ms:`, err.message);
delay = DEFAULT_DELAY;
}

View File

@@ -19,6 +19,8 @@ let currentCategory = 'all';
let searchGlobal = '';
let searchTerms = {};
let collapsedCards = new Set();
let toastTimer = null;
let prevServiceFingerprint = ''; /* tracks service data for incremental updates */
/* ── localStorage ── */
const LS_CARD = 'cred:card:collapsed:';
@@ -42,6 +44,8 @@ export function unmount() {
searchGlobal = '';
searchTerms = {};
collapsedCards.clear();
toastTimer = null;
prevServiceFingerprint = '';
}
/* ── shell ── */
@@ -66,7 +70,7 @@ function buildShell() {
/* services grid */
el('div', { class: 'services-grid', id: 'credentials-grid' }),
/* toast */
el('div', { class: 'copied-feedback', id: 'cred-toast' }, ['Copied to clipboard!']),
el('div', { class: 'copied-feedback', id: 'cred-toast' }, [t('creds.copied')]),
]);
}
@@ -81,7 +85,13 @@ function statItem(icon, id, label) {
/* ── fetch ── */
async function fetchCredentials() {
try {
const text = await fetch('/list_credentials').then(r => r.text());
const ac = tracker ? tracker.trackAbortController() : new AbortController();
const text = await fetch('/list_credentials', { signal: ac.signal }).then(r => r.text());
if (tracker) tracker.removeAbortController(ac);
/* guard: page may have unmounted while awaiting */
if (!tracker) return;
const doc = new DOMParser().parseFromString(text, 'text/html');
const tables = doc.querySelectorAll('table');
@@ -98,11 +108,20 @@ async function fetchCredentials() {
// Sort by most credentials first
serviceData.sort((a, b) => (b.credentials.rows?.length || 0) - (a.credentials.rows?.length || 0));
/* Compute a fingerprint of the data to skip DOM rebuild when nothing changed */
const fp = serviceData.map(s =>
`${s.service}:${s.credentials.rows.length}:${s.credentials.rows.map(r => Object.values(r).join('|')).join(',')}`
).join(';');
if (fp === prevServiceFingerprint) return; /* no changes — skip DOM rebuild */
prevServiceFingerprint = fp;
updateStats();
renderTabs();
renderServices();
applyPersistedCollapse();
} catch (err) {
if (err.name === 'AbortError') return;
console.error(`[${PAGE}] fetch error:`, err);
}
}
@@ -179,7 +198,7 @@ function renderTabs() {
empty(tabs);
cats.forEach(cat => {
const label = cat === 'all' ? 'All' : cat.toUpperCase();
const label = cat === 'all' ? t('common.all') : cat.toUpperCase();
const count = counts[cat] || 0;
const active = cat === currentCategory ? 'active' : '';
const tab = el('div', { class: `tab ${active}`, 'data-cat': cat }, [
@@ -231,7 +250,7 @@ function renderServices() {
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',
t('creds.noCredentials'),
]));
updateBadges();
return;
@@ -265,16 +284,16 @@ function createServiceCard(svc) {
/* 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('span', { class: 'service-count' }, [t('creds.credentialsCount', { count })]),
el('div', { class: 'search-container', onclick: e => e.stopPropagation() }, [
el('input', {
type: 'text', class: 'search-input', placeholder: 'Search...',
type: 'text', class: 'search-input', placeholder: t('creds.searchDots'),
'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',
class: 'download-button', title: t('creds.downloadCsv'),
onclick: (e) => downloadCSV(e, svc.service, svc.credentials)
}, ['💾']),
el('span', { class: 'collapse-indicator' }, ['▼']),
@@ -299,7 +318,7 @@ function createCredentialItem(row) {
class: `field-value ${val.trim() ? bubbleClass : ''}`,
'data-value': val,
onclick: (e) => copyToClipboard(e.currentTarget),
title: 'Click to copy',
title: t('creds.clickToCopy'),
}, [val]),
]);
}),
@@ -399,7 +418,8 @@ function copyToClipboard(el) {
showToast();
const bg = el.style.background;
el.style.background = '#4CAF50';
setTimeout(() => el.style.background = bg, 500);
if (tracker) tracker.trackTimeout(() => { el.style.background = bg; }, 500);
else setTimeout(() => { el.style.background = bg; }, 500);
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
@@ -416,7 +436,13 @@ function showToast() {
const toast = $('#cred-toast');
if (!toast) return;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
if (toastTimer != null) {
if (tracker) tracker.clearTrackedTimeout(toastTimer);
else clearTimeout(toastTimer);
}
toastTimer = tracker
? tracker.trackTimeout(() => { toast.classList.remove('show'); toastTimer = null; }, 1500)
: setTimeout(() => { toast.classList.remove('show'); toastTimer = null; }, 1500);
}
/* ── CSV export ── */

View File

@@ -1,5 +1,5 @@
/**
* Dashboard page module — matches web_old/index.html layout & behavior.
* Dashboard page module — modernized layout & behavior.
* Visibility-aware polling, resource cleanup, safe DOM (no innerHTML).
*/
@@ -38,62 +38,219 @@ export function unmount() {
if (tracker) { tracker.cleanupAll(); tracker = null; }
}
/* ======================== Layout (matches old index.html) ======================== */
/* ======================== Layout (Redesigned) ======================== */
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'])]),
]),
return el('div', { class: 'dashboard-container modern-dash' }, [
// Top Bar (LiveOps + Basic System)
el('div', { class: 'top-bar anim-enter' }, [
el('div', { class: 'liveops', id: 'liveops-card' }, [
el('span', { class: 'pulse-dot' }, []),
el('span', { class: 'liveops-title' }, [t('dash.liveOps') || 'Live Ops']),
el('span', { class: 'liveops-time', id: 'db-last-update' }, ['\u2014']),
]),
el('div', { class: 'sys-badges' }, [
el('span', { class: 'badge mode-badge', id: 'sys-mode' }, [t('dash.auto')]),
el('span', { class: 'badge uptime-badge', id: 'sys-uptime' }, ['00:00:00']),
])
]),
// Hero: Battery | Connectivity | Internet
el('section', { class: 'hero-grid' }, [
buildBatteryCard(),
buildConnCard(),
buildNetCard(),
]),
// KPI tiles
buildKpiGrid(),
// Main Content Grid
el('div', { class: 'dash-main' }, [
// Left / Top section
el('div', { class: 'dash-col-left anim-enter' }, [
// Hero: Battery + System Bars
el('div', { class: 'dash-card hero-card' }, [
el('div', { class: 'hero-left' }, [buildBatteryWidget()]),
el('div', { class: 'hero-divider' }, []),
el('div', { class: 'hero-right' }, [buildSystemBarsWidget()])
]),
// Connectivity Mini Grid
buildConnWidget(),
]),
// Right / Bottom section
el('div', { class: 'dash-col-right anim-enter' }, [
// KPI Grid
buildKpiWidget(),
// Footer info (GPS, OS info)
buildFooterWidget()
])
])
]);
}
/* ======================== Battery Card ======================== */
/* ======================== Widgets Builders ======================== */
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 buildBatteryWidget() {
return 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' }, [t('dash.lvl', { level: 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', class: 'hide-mobile' }, ['\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 buildSystemBarsWidget() {
const sysRow = (label, valId, maxId, barId, extraId) => {
return el('div', { class: 'sys-row' }, [
el('div', { class: 'sys-lbl' }, [
label,
el('span', { class: 'sys-vals' }, [
el('span', { id: valId }, ['0']),
maxId ? ` / ` : '',
maxId ? el('span', { id: maxId }, ['0']) : '',
extraId ? el('span', { id: extraId, class: 'sys-extra' }, []) : ''
])
]),
el('div', { class: 'bar' }, [el('i', { id: barId })])
]);
};
return el('div', { class: 'sys-bars' }, [
sysRow(t('dash.cpu'), 'cpu-pct', null, 'cpu-bar', null),
sysRow(t('dash.ram'), 'ram-used', 'ram-total', 'ram-bar', null),
sysRow(t('dash.disk'), 'sto-used', 'sto-total', 'sto-bar', null),
sysRow(t('dash.fds'), 'fds-used', 'fds-max', 'fds-bar', null)
]);
}
function buildConnWidget() {
const usbExtra = el('div', { class: 'conn-det-wrap' }, [
el('div', { class: 'conn-details', id: 'usb-details' }, [
el('span', { id: 'usb-gadget-state' }, [t('dash.off')]),
el('span', { id: 'usb-lease' }, ['\u2014']),
el('span', { id: 'usb-mode' }, ['\u2014'])
])
]);
const btExtra = el('div', { class: 'conn-det-wrap' }, [
el('div', { class: 'conn-details', id: 'bt-details' }, [
el('span', { id: 'bt-gadget-state' }, [t('dash.off')]),
el('span', { id: 'bt-lease' }, ['\u2014']),
el('span', { id: 'bt-connected' }, ['\u2014'])
])
]);
return el('div', { class: 'dash-card net-card anim-enter' }, [
el('div', { class: 'net-header' }, [
el('div', { class: 'net-title' }, [t('dash.connectivity') || 'Connectivity']),
el('div', { class: 'net-badge-wrap' }, [
t('dash.internet') + ': ', el('span', { class: 'net-badge', id: 'net-badge' }, [t('dash.no')])
])
]),
el('div', { class: 'conn-grid' }, [
el('div', { class: 'conn-box', id: 'row-wifi' }, [
el('div', { class: 'conn-icon' }, [svgIcon(null, '0 0 24 24', [{ 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: 'conn-lbl' }, [t('dash.wifi')]),
el('div', { class: 'conn-state' }, [el('span', { class: 'state-pill', id: 'wifi-state' }, [t('dash.off')])]),
el('div', { class: 'conn-det-wrap' }, [el('div', { id: 'wifi-details' }, []), el('div', { id: 'wifi-under' }, [])])
]),
el('div', { class: 'conn-box', id: 'row-eth' }, [
el('div', { class: 'conn-icon' }, [svgIcon(null, '0 0 24 24', [{ 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: 'conn-lbl' }, [t('dash.ethernet')]),
el('div', { class: 'conn-state' }, [el('span', { class: 'state-pill', id: 'eth-state' }, [t('dash.off')])]),
el('div', { class: 'conn-det-wrap' }, [el('div', { id: 'eth-details' }, []), el('div', { id: 'eth-under' }, [])])
]),
el('div', { class: 'conn-box', id: 'row-usb' }, [
el('div', { class: 'conn-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: 'conn-lbl' }, [t('dash.usb')]),
el('div', { class: 'conn-state' }, [el('span', { class: 'state-pill', id: 'usb-state' }, [t('dash.off')])]),
usbExtra
]),
el('div', { class: 'conn-box', id: 'row-bt' }, [
el('div', { class: 'conn-icon' }, [svgIcon(null, '0 0 24 24', [{ d: 'M7 7l10 10-5 5V2l5 5L7 17' }])]),
el('div', { class: 'conn-lbl' }, [t('dash.bluetooth')]),
el('div', { class: 'conn-state' }, [el('span', { class: 'state-pill', id: 'bt-state' }, [t('dash.off')])]),
btExtra
]),
])
]);
}
function buildKpiWidget() {
const kpi = (id, labelKey, valId, iconPaths, extraChild) => {
return el('div', { class: 'kpi-box', id }, [
el('div', { class: 'kpi-ico' }, [svgIcon(null, '0 0 24 24', iconPaths)]),
el('div', { class: 'kpi-val', id: valId }, ['0']),
el('div', { class: 'kpi-lbl' }, [t(labelKey) || labelKey]),
extraChild ? extraChild : ''
]);
};
const kpiHosts = el('div', { class: 'kpi-box', id: 'kpi-hosts' }, [
el('div', { class: 'kpi-ico' }, [svgIcon(null, '0 0 24 24', [{ d: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2' }, { tag: 'circle', cx: '12', cy: '7', r: '4' }])]),
el('div', { class: 'kpi-val multi-val' }, [
el('span', { id: 'val-present' }, ['0']), ' / ',
el('span', { id: 'val-known', class: 'dim' }, ['0'])
]),
el('div', { class: 'kpi-lbl' }, [t('dash.hostsAlive') || 'HOSTS'])
]);
const kpiVulns = el('div', { class: 'kpi-box', id: 'kpi-vulns' }, [
el('div', { class: 'kpi-ico' }, [svgIcon(null, '0 0 24 24', [{ d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, { d: 'M12 8v4' }, { d: 'M12 16h.01' }])]),
el('div', { class: 'kpi-val' }, [el('span', { id: 'val-vulns' }, ['0'])]),
el('div', { class: 'kpi-lbl' }, [t('vulns.title') || 'VULNS']),
el('div', { class: 'kpi-extra' }, [el('span', { id: 'vuln-delta', class: 'delta' }, ['\u2014'])])
]);
return el('div', { class: 'kpi-grid' }, [
kpiHosts,
kpi('kpi-ports-alive', 'netkb.openPorts', 'val-open-ports-alive', [{ d: 'M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z' }, { d: 'M8 9.5v5.523c0 .548.243 1.07.668 1.42L12 19l3.332-2.557A2.25 2.25 0 0 0 16 15.023V9.5' }]),
kpi('kpi-wardrive', 'dash.wifiKnown', 'val-wardrive-known', [{ d: 'M5 12.55a11 11 0 0 1 14.08 0' }, { d: 'M1.42 9a16 16 0 0 1 21.16 0' }, { d: 'M8.53 16.11a6 6 0 0 1 6.95 0' }, { tag: 'line', x1: '12', y1: '20', x2: '12.01', y2: '20' }]),
kpi('kpi-zombies', 'dash.zombies', 'val-zombies', [{ d: 'M12 21a9 9 0 0 0 9-9c0-5-4-9-9-9s-9 4-9 9a9 9 0 0 0 9 9z' }, { d: 'M12 8v4' }, { d: 'M12 16h.01' }]),
kpi('kpi-creds', 'creds.title', 'val-creds', [{ d: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }]),
kpi('kpi-files', 'dash.dataFiles', 'val-files', [{ d: 'M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z' }, { d: 'M13 2v7h7' }]),
kpiVulns,
kpi('kpi-scripts', 'dash.attackScripts', 'val-scripts', [{ d: 'M12 20.94c1.5 0 2.75 1.06 4 1.06 3 0 6-8 6-12.22A4.91 4.91 0 0 0 17 5c-2.22 0-4 1.44-5 2-1-.56-2.78-2-5-2a4.9 4.9 0 0 0-5 4.78C2 14 5 22 8 22c1.25 0 2.5-1.06 4-1.06Z' }, { d: 'M10 2c1 .5 2 2 2 5' }])
]);
}
function buildFooterWidget() {
return el('div', { class: 'dash-card footer-card anim-enter' }, [
el('div', { class: 'footer-col' }, [
el('div', { class: 'f-title' }, [t('dash.system')]),
el('div', { class: 'f-val', id: 'sys-os' }, [t('dash.osLabel') + ': \u2014']),
el('div', { class: 'f-val', id: 'sys-arch' }, [t('dash.arch') + ': \u2014']),
el('div', { class: 'f-val', id: 'sys-model' }, [t('dash.model') + ': \u2014']),
el('div', { class: 'f-val', id: 'sys-epd' }, [t('dash.waveshare') + ': \u2014']),
]),
el('div', { class: 'footer-col gps-col' }, [
el('div', { class: 'f-title' }, [t('dash.gps')]),
el('div', { class: 'f-val gps-state', id: 'gps-state' }, [t('dash.off')]),
el('div', { class: 'f-val', id: 'gps-info' }, ['\u2014']),
el('div', { class: 'f-title mt' }, [t('dash.bjorn')]),
el('div', { class: 'f-val', id: 'bjorn-age' }, [t('dash.age') + ': \u2014']),
])
]);
}
/* ======================== SVG Helpers ======================== */
function createBatterySVG() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
@@ -104,7 +261,6 @@ function createBatterySVG() {
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%');
@@ -114,7 +270,7 @@ function createBatterySVG() {
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%');
@@ -127,16 +283,15 @@ function createBatterySVG() {
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');
@@ -146,7 +301,6 @@ function createBatterySVG() {
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');
@@ -161,164 +315,6 @@ function svgIcon(id, viewBox, elems, hidden) {
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) {
@@ -391,7 +387,7 @@ function normalizeStats(payload) {
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_mode: conn.usb_mode || t('dash.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,
@@ -412,12 +408,12 @@ async function fetchBjornStats() {
async function fetchAndPaintHeavy() {
const data = await fetchBjornStats();
if (data) paintFull(data);
if (data && tracker) paintFull(data);
}
async function fetchAndPaintLight() {
const data = await fetchBjornStats();
if (!data) return;
if (!data || !tracker) return;
if (data.system) paintCpuRam(data.system);
if (data.connectivity) paintConnectivity(data.connectivity);
}
@@ -481,7 +477,6 @@ function updateRingColors(percent) {
/* ---------- 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));
@@ -499,7 +494,6 @@ function paintFull(data) {
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) {
@@ -511,22 +505,19 @@ function paintFull(data) {
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}`);
if (data.bjorn_level != null) setById('bjorn-level', t('dash.lvl', { level: 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';
badge.textContent = data.internet_access ? t('dash.yes') : t('dash.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);
@@ -537,56 +528,49 @@ function paintFull(data) {
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`);
dEl.textContent = delta === 0 ? t('dash.equalSinceScan')
: (delta > 0 ? `\u2212${Math.abs(delta)} ${t('dash.sinceScan')}` : `+${Math.abs(delta)} ${t('dash.sinceScan')}`);
}
// 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));
if (document.getElementById('sto-total')) 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'}`);
setById('sys-os', t('dash.osLabel') + ': ' + `${sys.os_name || '\u2014'}${sys.os_version ? ` ${sys.os_version}` : ''}`);
setById('sys-arch', t('dash.arch') + ': ' + (sys.arch || '\u2014'));
setById('sys-model', t('dash.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})` : ''}`);
setById('sys-epd', t('dash.waveshare') + ': ' + `${epd === true ? t('dash.on') : epd === false ? t('dash.off') : '\u2014'}${sys.waveshare_epd_type ? ` (${sys.waveshare_epd_type})` : ''}`);
// Mode + uptime
setById('sys-mode', (data.mode || '\u2014').toString().toUpperCase());
const modeStr = (data.mode || '').toString().toUpperCase();
setById('sys-mode', modeStr === 'AUTO' ? t('dash.auto') : modeStr === 'MANUAL' ? t('dash.manual') : modeStr || '\u2014');
startUptime(data.uptime || '00:00:00');
// Age
setById('bjorn-age', data.first_init_ts ? `Bjorn age: ${humanAge(data.first_init_ts)}` : '');
setById('bjorn-age', data.first_init_ts ? t('dash.age') + ': ' + humanAge(data.first_init_ts) : t('dash.age') + ': \u2014');
// GPS
const gps = data.gps || {};
setById('gps-state', gps.connected ? 'ON' : 'OFF');
setById('gps-state', gps.connected ? t('dash.on') : t('dash.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')
? t('dash.fix') + ': ' + `${gps.fix_quality} \u2022 ` + t('dash.sats') + ': ' + `${gps.sats ?? '\u2014'} \u2022 ${gps.lat ?? '\u2014'}, ${gps.lon ?? '\u2014'} \u2022 ${gps.speed ?? '\u2014'}`
: t('dash.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());
setById('db-last-update', ts.toLocaleTimeString());
}
/* ---------- CPU / RAM (5 s) ---------- */
@@ -599,12 +583,12 @@ function paintCpuRam(sys) {
const ramUsed = sys.ram_used_bytes ?? 0;
const ramTot = sys.ram_total_bytes ?? 0;
setById('ram-used', fmtBytes(ramUsed));
setById('ram-total', fmtBytes(ramTot));
if (document.getElementById('ram-total')) 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 ?? '');
if (document.getElementById('fds-max')) setById('fds-max', sys.max_fds ?? '');
setPctBar('fds-bar', sys.max_fds ? (sys.open_fds / sys.max_fds) * 100 : 0);
}
}
@@ -614,49 +598,44 @@ function paintCpuRam(sys) {
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');
setById('wifi-state', c.wifi ? t('dash.on') : t('dash.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 (c.wifi_ssid) parts.push(detailPair(t('dash.ssid'), c.wifi_ssid));
if (c.wifi_ip) parts.push(detailPair(t('dash.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');
setById('eth-state', c.ethernet ? t('dash.on') : t('dash.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'; }
if (eDet) { eDet.textContent = ''; if (c.eth_ip) eDet.appendChild(detailPair(t('dash.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-state', usbG ? t('dash.on') : t('dash.off'));
setById('usb-gadget-state', usbG ? t('dash.on') : t('dash.off'));
setById('usb-lease', c.usb_lease_ip || '\u2014');
setById('usb-mode', c.usb_mode || 'Device');
setById('usb-mode', c.usb_mode || t('dash.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-state', btG ? t('dash.on') : t('dash.off'));
setById('bt-gadget-state', btG ? t('dash.on') : t('dash.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;
@@ -668,8 +647,8 @@ function detailPair(k, v) {
function underline(gw, dns) {
const p = [];
if (gw) p.push(`GW: ${gw}`);
if (dns) p.push(`DNS: ${dns}`);
if (gw) p.push(t('dash.gw') + ': ' + gw);
if (dns) p.push(t('dash.dns') + ': ' + dns);
return p.length ? p.join(' \u2022 ') : '\u2014';
}
@@ -679,12 +658,17 @@ function startUptime(str) {
stopUptime();
uptimeSecs = parseUptime(str);
tickUptime();
uptimeTimer = tracker?.trackInterval(() => { uptimeSecs += 1; tickUptime(); }, 1000);
if (tracker) {
uptimeTimer = tracker.trackInterval(() => { uptimeSecs += 1; tickUptime(); }, 1000);
}
}
function stopUptime() {
if (uptimeTimer && tracker) tracker.clearTrackedInterval(uptimeTimer);
uptimeTimer = null;
if (uptimeTimer != null) {
if (tracker) tracker.clearTrackedInterval(uptimeTimer);
else clearInterval(uptimeTimer);
uptimeTimer = null;
}
}
function tickUptime() { setById('sys-uptime', fmtUptime(uptimeSecs)); }

View File

@@ -27,6 +27,8 @@ let rowLimit = 100;
let sidebarFilter = '';
let liveRefresh = false;
let disposeSidebarLayout = null;
let searchDebounce = null;
let loadSequence = 0;
/* ── lifecycle ── */
export async function mount(container) {
@@ -45,6 +47,8 @@ export async function mount(container) {
}
export function unmount() {
clearTimeout(searchDebounce);
searchDebounce = null;
if (disposeSidebarLayout) { disposeSidebarLayout(); disposeSidebarLayout = null; }
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
@@ -52,13 +56,14 @@ export function unmount() {
dirty = new Map(); selected = new Set();
sortCol = null; sortDir = 'asc'; searchText = '';
rowLimit = 100; sidebarFilter = ''; liveRefresh = false;
loadSequence = 0;
}
/* ── shell ── */
function buildShell() {
const hideLabel = (() => {
const v = t('common.hide');
return v && v !== 'common.hide' ? v : 'Hide';
return v && v !== 'common.hide' ? v : t('common.hide');
})();
return el('div', { class: 'db-container page-with-sidebar' }, [
/* sidebar */
@@ -70,7 +75,7 @@ function buildShell() {
]),
el('div', { class: 'sidecontent' }, [
el('div', { class: 'tree-head' }, [
el('div', { class: 'pill' }, ['Tables']),
el('div', { class: 'pill' }, [t('db.tables')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', type: 'button', onclick: loadCatalog }, [t('common.refresh')]),
]),
@@ -86,7 +91,7 @@ function buildShell() {
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'),
type: 'text', class: 'db-search-input', placeholder: t('db.searchRows'),
oninput: onSearch
}),
el('select', { class: 'db-limit-select', onchange: onLimitChange }, [
@@ -99,17 +104,17 @@ function buildShell() {
]),
]),
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']),
el('button', { class: 'btn', id: 'db-btn-save', onclick: onSave }, [t('db.saveChanges')]),
el('button', { class: 'btn', id: 'db-btn-discard', onclick: onDiscard }, [t('db.discardChanges')]),
el('button', { class: 'btn', onclick: () => loadTable(activeTable) }, [t('common.refresh')]),
el('button', { class: 'btn', onclick: onAddRow }, [t('db.addRowBtn')]),
el('button', { class: 'btn btn-danger', onclick: onDeleteSelected }, [t('db.deleteSelected')]),
el('button', { class: 'btn', onclick: () => exportTable('csv') }, [t('db.csv')]),
el('button', { class: 'btn', onclick: () => exportTable('json') }, [t('db.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', { class: 'db-empty-state' }, [
el('div', { style: 'font-size:3rem;margin-bottom:12px;opacity:.5' }, ['\u{1F5C4}\uFE0F']),
t('db.selectTableFromSidebar'),
]),
@@ -117,9 +122,9 @@ function buildShell() {
/* 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']),
el('button', { class: 'btn btn-danger btn-sm', onclick: onVacuum }, [t('db.vacuum')]),
el('button', { class: 'btn btn-danger btn-sm', onclick: onTruncate }, [t('db.truncate')]),
el('button', { class: 'btn btn-danger btn-sm', onclick: onDrop }, [t('db.drop')]),
]),
/* status */
el('div', { class: 'db-status', id: 'db-status' }),
@@ -172,20 +177,21 @@ function renderTree() {
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' : ''}`,
el('button', {
type: 'button',
class: `db-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,
el('span', { class: 'db-tree-item-name' }, [item.name]),
])
),
]));
};
renderGroup('Tables', tables);
renderGroup('Views', views);
renderGroup(t('db.tables'), tables);
renderGroup(t('db.views'), views);
if (catalog.length === 0) {
tree.appendChild(el('div', { style: 'text-align:center;padding:20px;opacity:.5' }, [t('db.noTables')]));
@@ -202,8 +208,11 @@ async function selectTable(name) {
activeTable = name;
sortCol = null; sortDir = 'asc';
searchText = ''; dirty.clear(); selected.clear();
const searchInput = $('.db-search-input');
if (searchInput) searchInput.value = '';
renderTree();
showToolbar(true);
closeSidebarOnMobile();
await loadTable(name);
}
@@ -219,6 +228,7 @@ function showToolbar(show) {
/* ── load table data ── */
async function loadTable(name) {
if (!name) return;
const seq = ++loadSequence;
setStatus(t('common.loading'));
try {
const params = new URLSearchParams();
@@ -227,14 +237,16 @@ async function loadTable(name) {
if (searchText) params.set('search', searchText);
const data = await api.get(`/api/db/table/${encodeURIComponent(name)}?${params}`, { timeout: 10000 });
if (seq !== loadSequence) return;
tableData = data;
renderTable();
setStatus(`${data.rows?.length || 0} of ${data.total ?? '?'} rows`);
setStatus(t('db.rowsInfo', { shown: data.rows?.length || 0, total: data.total ?? '?' }));
} catch (err) {
if (seq !== loadSequence) return;
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')])); }
if (wrap) { empty(wrap); wrap.appendChild(el('div', { class: 'db-empty-state' }, [t('db.errorLoadingData')])); }
}
}
@@ -248,7 +260,7 @@ function renderTable() {
const rows = tableData.rows || [];
if (cols.length === 0) {
wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.emptyTable')]));
wrap.appendChild(el('div', { class: 'db-empty-state' }, [t('db.emptyTable')]));
return;
}
@@ -325,8 +337,12 @@ function toggleSort(col) {
/* ── search ── */
function onSearch(e) {
searchText = e.target.value;
loadTable(activeTable);
const nextValue = e.target.value;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchText = nextValue;
loadTable(activeTable);
}, 220);
}
/* ── limit ── */
@@ -359,7 +375,7 @@ function onSelectAll(e) {
function toggleRowSelection(pk, checked) {
if (checked) selected.add(pk); else selected.delete(pk);
const tr = document.querySelector(`tr.db-tr[data-pk="${pk}"]`);
const tr = document.querySelector(`.db-container tr.db-tr[data-pk="${pk}"]`);
if (tr) tr.classList.toggle('selected', checked);
}
@@ -485,7 +501,7 @@ async function onDrop() {
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')])); }
if (wrap) { empty(wrap); wrap.appendChild(el('div', { class: 'db-empty-state' }, [t('db.tableDropped')])); }
await loadCatalog();
} catch (err) {
toast(`${t('db.dropFailed')}: ${err.message}`, 3000, 'error');
@@ -497,3 +513,10 @@ function setStatus(msg) {
const el2 = $('#db-status');
if (el2) el2.textContent = msg || '';
}
function closeSidebarOnMobile() {
if (window.matchMedia('(max-width: 900px)').matches) {
const hideBtn = $('#hideSidebar');
if (hideBtn) hideBtn.click();
}
}

View File

@@ -214,13 +214,17 @@ function updateStaticI18n() {
async function loadAllFiles() {
setStatus(L('common.loading', 'Loading...'));
try {
const response = await fetch('/list_files');
const ac = tracker ? tracker.trackAbortController() : new AbortController();
const response = await fetch('/list_files', { signal: ac.signal });
if (tracker) tracker.removeAbortController(ac);
if (!tracker) return; /* unmounted while awaiting */
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) {
if (err.name === 'AbortError') return;
console.error(`[${PAGE}] loadAllFiles:`, err);
allFiles = [];
renderCurrentFolder();

536
web/js/pages/loki.js Normal file
View File

@@ -0,0 +1,536 @@
/**
* Loki — HID Attack Suite SPA page
* Script editor, library, job management, quick-type.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast, escapeHtml, confirmT } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'loki';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let lokiEnabled = false;
let status = {};
let scripts = [];
let payloads = [];
let jobs = [];
let layouts = ['us'];
let currentScript = { id: null, name: '', content: '' };
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refreshJobs, 4000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
scripts = [];
payloads = [];
jobs = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'loki-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'loki-header' }, [
el('h1', { class: 'loki-title' }, [
el('span', { class: 'loki-title-icon' }, ['\uD83D\uDC0D']),
el('span', { 'data-i18n': 'loki.title' }, [t('loki.title')]),
]),
el('div', { class: 'loki-controls' }, [
el('span', { 'data-i18n': 'loki.enable' }, [t('loki.enable')]),
el('input', { type: 'checkbox', class: 'loki-toggle', id: 'loki-toggle' }),
]),
]),
/* ── Status bar ───────────────────────────────────── */
el('div', { class: 'loki-status-bar', id: 'loki-status-bar' }),
/* ── Grid: editor + library ───────────────────────── */
el('div', { class: 'loki-grid', id: 'loki-grid' }, [
/* Editor column */
el('div', { class: 'loki-editor-panel' }, [
el('textarea', {
class: 'loki-editor',
id: 'loki-editor',
spellcheck: 'false',
placeholder: '// HIDScript editor\nlayout(\'us\');\ndelay(1000);\npress("GUI r");\ndelay(500);\ntype("notepad\\n");\ndelay(1000);\ntype("Hello from Loki!");',
}),
el('div', { class: 'loki-editor-toolbar' }, [
el('button', { class: 'loki-btn primary', id: 'loki-run' }, ['\u25B6 ', t('loki.run')]),
el('button', { class: 'loki-btn', id: 'loki-save' }, ['\uD83D\uDCBE ', t('loki.save')]),
el('button', { class: 'loki-btn', id: 'loki-new' }, ['\uD83D\uDCC4 ', t('loki.new')]),
el('select', { id: 'loki-layout-select' }),
]),
/* Quick type row */
el('div', { class: 'loki-quick-row' }, [
el('input', {
type: 'text', class: 'loki-quick-input', id: 'loki-quick-input',
placeholder: t('loki.quick_placeholder'),
}),
el('button', { class: 'loki-btn', id: 'loki-quick-send' }, [t('loki.quick_send')]),
]),
]),
/* Library column */
el('div', { class: 'loki-library' }, [
/* Payloads section */
el('div', { class: 'loki-library-section' }, [
el('div', { class: 'loki-library-heading', id: 'loki-payloads-heading' }, [t('loki.payloads')]),
el('ul', { class: 'loki-library-list', id: 'loki-payloads-list' }),
]),
/* Custom scripts section */
el('div', { class: 'loki-library-section' }, [
el('div', { class: 'loki-library-heading', id: 'loki-scripts-heading' }, [t('loki.custom_scripts')]),
el('ul', { class: 'loki-library-list', id: 'loki-scripts-list' }),
]),
]),
]),
/* ── Jobs panel ───────────────────────────────────── */
el('div', { class: 'loki-jobs' }, [
el('div', { class: 'loki-jobs-header' }, [
el('h3', {}, [t('loki.jobs')]),
el('button', { class: 'loki-btn', id: 'loki-clear-jobs' }, [t('loki.clear_completed')]),
]),
el('div', { id: 'loki-jobs-body' }),
]),
]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
// Toggle enable/disable
const tog = $('#loki-toggle', root);
if (tog) tog.addEventListener('change', async () => {
const enabled = tog.checked;
const res = await api.post('/api/loki/toggle', { enabled });
if (res?.status === 'ok') {
lokiEnabled = enabled;
toast(enabled ? t('loki.enabled_msg') : t('loki.disabled_msg'));
await refresh();
}
});
// Run
const runBtn = $('#loki-run', root);
if (runBtn) runBtn.addEventListener('click', runScript);
// Save
const saveBtn = $('#loki-save', root);
if (saveBtn) saveBtn.addEventListener('click', saveScript);
// New
const newBtn = $('#loki-new', root);
if (newBtn) newBtn.addEventListener('click', newScript);
// Quick type
const quickBtn = $('#loki-quick-send', root);
if (quickBtn) quickBtn.addEventListener('click', quickType);
const quickInput = $('#loki-quick-input', root);
if (quickInput) quickInput.addEventListener('keydown', e => {
if (e.key === 'Enter') quickType();
});
// Clear jobs
const clearBtn = $('#loki-clear-jobs', root);
if (clearBtn) clearBtn.addEventListener('click', async () => {
await api.post('/api/loki/jobs/clear', {});
await refreshJobs();
});
// Layout select
const layoutSel = $('#loki-layout-select', root);
if (layoutSel) layoutSel.addEventListener('change', () => {
// Layout is sent per-run, stored in editor state
});
// Tab on editor inserts two spaces
const editor = $('#loki-editor', root);
if (editor) editor.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
});
}
/* ── Data fetch ────────────────────────────────────────── */
async function refresh() {
const [sRes, scrRes, payRes, jobRes, layRes] = await Promise.all([
api.get('/api/loki/status'),
api.get('/api/loki/scripts'),
api.get('/api/loki/payloads'),
api.get('/api/loki/jobs'),
api.get('/api/loki/layouts'),
]);
if (sRes) { status = sRes; lokiEnabled = sRes.enabled; }
if (scrRes) scripts = scrRes.scripts || [];
if (payRes) payloads = payRes.payloads || [];
if (jobRes) jobs = jobRes.jobs || [];
if (layRes) layouts = layRes.layouts || ['us'];
paint();
}
async function refreshJobs() {
const [sRes, jobRes] = await Promise.all([
api.get('/api/loki/status'),
api.get('/api/loki/jobs'),
]);
if (sRes) { status = sRes; lokiEnabled = sRes.enabled; }
if (jobRes) jobs = jobRes.jobs || [];
paintStatus();
paintJobs();
}
/* ── Render ────────────────────────────────────────────── */
function paint() {
paintToggle();
paintStatus();
paintLayouts();
paintPayloads();
paintScripts();
paintJobs();
paintDisabledState();
}
function paintToggle() {
const tog = $('#loki-toggle', root);
if (tog) tog.checked = lokiEnabled;
}
function paintStatus() {
const bar = $('#loki-status-bar', root);
if (!bar) return;
empty(bar);
const running = status.running;
const gadget = status.gadget_ready;
const installed = status.gadget_installed !== false;
if (!installed) {
bar.append(
statusItem(t('loki.gadget_label'), t('loki.not_installed') || 'Not installed', false),
);
return;
}
bar.append(
statusItem(t('loki.status_label'), running ? t('loki.running') : t('loki.idle'), running),
statusItem(t('loki.gadget_label'), gadget ? t('loki.ready') : t('loki.not_ready'), gadget),
statusItem(t('loki.layout_label'), (status.layout || 'us').toUpperCase()),
statusItem(t('loki.jobs_label'), `${status.jobs_running || 0} ${t('loki.running_lc')}`),
);
}
function statusItem(label, value, dotState) {
const children = [];
if (dotState !== undefined) {
children.push(el('span', { class: `dot ${dotState ? 'on' : 'off'}` }));
}
children.push(el('span', { class: 'label' }, [label + ': ']));
children.push(el('span', { class: 'value' }, [String(value)]));
return el('span', { class: 'loki-status-item' }, children);
}
function paintLayouts() {
const sel = $('#loki-layout-select', root);
if (!sel) return;
empty(sel);
for (const lay of layouts) {
const opt = el('option', { value: lay }, [lay.toUpperCase()]);
if (lay === (status.layout || 'us')) opt.selected = true;
sel.appendChild(opt);
}
}
function paintPayloads() {
const list = $('#loki-payloads-list', root);
if (!list) return;
empty(list);
for (const p of payloads) {
const item = el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', title: p.description || '' }, [p.name]),
]);
item.addEventListener('click', () => loadPayload(p));
list.appendChild(item);
}
if (!payloads.length) {
list.appendChild(el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', style: 'color:var(--muted)' }, [t('loki.no_payloads')]),
]));
}
}
function paintScripts() {
const list = $('#loki-scripts-list', root);
if (!list) return;
empty(list);
for (const s of scripts) {
const item = el('li', {
class: `loki-library-item${currentScript.id === s.id ? ' active' : ''}`,
}, [
el('span', { class: 'name' }, [s.name]),
el('button', {
class: 'loki-btn danger',
style: 'padding:2px 6px;font-size:0.65rem;',
title: t('loki.delete'),
}, ['\u2715']),
]);
// Click name → load
item.querySelector('.name').addEventListener('click', () => loadScript(s));
// Click delete
item.querySelector('.loki-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(t('loki.confirm_delete', { name: s.name }))) return;
await api.post('/api/loki/script/delete', { id: s.id });
await refresh();
});
list.appendChild(item);
}
if (!scripts.length) {
list.appendChild(el('li', { class: 'loki-library-item' }, [
el('span', { class: 'name', style: 'color:var(--muted)' }, [t('loki.no_scripts')]),
]));
}
}
function paintJobs() {
const body = $('#loki-jobs-body', root);
if (!body) return;
empty(body);
if (!jobs.length) {
body.appendChild(el('div', { class: 'loki-jobs-empty' }, [t('loki.no_jobs')]));
return;
}
const table = el('table', { class: 'loki-jobs-table' }, [
el('thead', {}, [
el('tr', {}, [
el('th', {}, ['ID']),
el('th', {}, [t('loki.script')]),
el('th', {}, [t('loki.status_col')]),
el('th', {}, [t('loki.started')]),
el('th', {}, [t('loki.actions')]),
]),
]),
el('tbody', {}, jobs.slice(0, 20).map(j => {
const badge = el('span', { class: `loki-badge ${j.status}` }, [
statusIcon(j.status), ' ', j.status,
]);
const row = el('tr', {}, [
el('td', {}, [j.id ? j.id.substring(0, 6) : '...']),
el('td', {}, [j.script_name || '-']),
el('td', {}, [badge]),
el('td', {}, [formatTime(j.started_at)]),
el('td', {}),
]);
const actions = row.lastChild;
if (j.status === 'running') {
const cancelBtn = el('button', { class: 'loki-btn danger', style: 'padding:2px 8px;font-size:0.7rem;' }, [t('loki.cancel')]);
cancelBtn.addEventListener('click', async () => {
await api.post('/api/loki/job/cancel', { job_id: j.id });
await refreshJobs();
});
actions.appendChild(cancelBtn);
}
if (j.output) {
const outBtn = el('button', { class: 'loki-btn', style: 'padding:2px 8px;font-size:0.7rem;' }, [t('loki.output')]);
outBtn.addEventListener('click', () => {
alert(j.output || t('loki.no_output'));
});
actions.appendChild(outBtn);
}
return row;
})),
]);
body.appendChild(table);
}
function paintDisabledState() {
const grid = $('#loki-grid', root);
if (!grid) return;
const installed = status.gadget_installed !== false;
if (!installed) {
grid.classList.add('loki-disabled-overlay');
// Show install banner
let banner = $('#loki-install-banner', root);
if (!banner) {
banner = el('div', { id: 'loki-install-banner', class: 'loki-install-banner' }, [
el('p', {}, [t('loki.install_msg') || 'HID gadget not installed. Install it and reboot to enable Loki.']),
el('button', { class: 'loki-btn primary', id: 'loki-install-btn' }, [
t('loki.install_btn') || 'Install HID Gadget & Reboot',
]),
]);
grid.parentNode.insertBefore(banner, grid);
$('#loki-install-btn', root).addEventListener('click', installGadget);
}
} else if (!lokiEnabled) {
grid.classList.add('loki-disabled-overlay');
// Remove install banner if present
const banner = $('#loki-install-banner', root);
if (banner) banner.remove();
} else {
grid.classList.remove('loki-disabled-overlay');
const banner = $('#loki-install-banner', root);
if (banner) banner.remove();
}
}
async function installGadget() {
const btn = $('#loki-install-btn', root);
if (btn) { btn.disabled = true; btn.textContent = 'Installing...'; }
const res = await api.post('/api/loki/install', {});
if (res?.success) {
toast(res.message || 'Installed!');
if (res.reboot_required) {
if (confirm(t('loki.reboot_confirm') || 'HID gadget installed. Reboot now?')) {
await api.post('/api/loki/reboot', {});
toast('Rebooting...');
}
}
} else {
toast(res?.message || 'Installation failed', 'error');
if (btn) { btn.disabled = false; btn.textContent = t('loki.install_btn') || 'Install HID Gadget & Reboot'; }
}
}
/* ── Actions ───────────────────────────────────────────── */
async function runScript() {
const editor = $('#loki-editor', root);
if (!editor) return;
const content = editor.value.trim();
if (!content) { toast(t('loki.empty_script'), 'warn'); return; }
const name = currentScript.name || 'editor';
const res = await api.post('/api/loki/script/run', { content, name });
if (res?.status === 'ok') {
toast(t('loki.job_started', { id: res.job_id }));
await refreshJobs();
} else {
toast(res?.message || t('loki.run_error'), 'error');
}
}
async function saveScript() {
const editor = $('#loki-editor', root);
if (!editor) return;
const content = editor.value.trim();
if (!content) { toast(t('loki.empty_script'), 'warn'); return; }
let name = currentScript.name;
if (!name) {
name = prompt(t('loki.script_name_prompt'), 'my_script');
if (!name) return;
}
const res = await api.post('/api/loki/script/save', {
id: currentScript.id || undefined,
name,
content,
description: '',
});
if (res?.status === 'ok') {
toast(t('loki.saved'));
currentScript.name = name;
await refresh();
} else {
toast(res?.message || t('loki.save_error'), 'error');
}
}
function newScript() {
const editor = $('#loki-editor', root);
if (editor) editor.value = '';
currentScript = { id: null, name: '', content: '' };
paintScripts();
}
async function loadScript(s) {
// Fetch full content
const res = await api.get(`/api/loki/script?id=${s.id}`);
if (res?.script) {
const editor = $('#loki-editor', root);
if (editor) editor.value = res.script.content || '';
currentScript = { id: s.id, name: s.name, content: res.script.content };
paintScripts();
}
}
function loadPayload(p) {
const editor = $('#loki-editor', root);
if (editor) editor.value = p.content || '';
currentScript = { id: null, name: p.name, content: p.content };
paintScripts();
}
async function quickType() {
const input = $('#loki-quick-input', root);
if (!input) return;
const text = input.value;
if (!text) return;
const res = await api.post('/api/loki/quick', { text });
if (res?.status === 'ok') {
toast(t('loki.quick_sent'));
input.value = '';
await refreshJobs();
} else {
toast(res?.message || t('loki.quick_error'), 'error');
}
}
/* ── Helpers ───────────────────────────────────────────── */
function statusIcon(status) {
switch (status) {
case 'running': return '\u26A1';
case 'succeeded': return '\u2705';
case 'failed': return '\u274C';
case 'cancelled': return '\u23F9';
case 'pending': return '\u23F3';
default: return '\u2022';
}
}
function formatTime(iso) {
if (!iso) return '-';
try {
const d = new Date(iso);
return d.toLocaleTimeString();
} catch {
return iso;
}
}

View File

@@ -16,6 +16,8 @@ let currentSort = 'name';
let sortDirection = 'asc';
let searchTerm = '';
let searchTimer = null;
let expandedDirs = new Set();
let treeExpansionInitialized = false;
const FILE_ICONS = {
ssh: '🔐',
@@ -49,6 +51,8 @@ export function unmount() {
currentSort = 'name';
sortDirection = 'asc';
searchTerm = '';
expandedDirs = new Set();
treeExpansionInitialized = false;
}
function buildShell() {
@@ -71,7 +75,7 @@ function buildShell() {
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 active', id: 'treeViewBtn', title: t('loot.treeView'), 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') }, ['⬇️']),
@@ -194,10 +198,12 @@ 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');
throw new Error(t('common.error'));
}
fileData = data.data;
expandedDirs = new Set();
treeExpansionInitialized = false;
processFiles();
updateStats();
renderContent();
@@ -322,7 +328,7 @@ function renderTabs(categories) {
if (!tabs) return;
empty(tabs);
tabs.appendChild(tabNode('all', 'All', true));
tabs.appendChild(tabNode('all', t('common.all'), true));
for (const cat of categories) {
tabs.appendChild(tabNode(cat, cat.toUpperCase(), false));
}
@@ -369,33 +375,34 @@ function renderTreeView(container, autoExpand = false) {
return;
}
if (!treeExpansionInitialized && !searchTerm) {
expandRootDirectories(filteredData);
treeExpansionInitialized = true;
}
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) {
function filterItems(items, path = '') {
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)
? filterItems(item.children, dirPath)
: [];
const nameMatch = String(item.name || '').toLowerCase().includes(searchTerm);
const dirMatchesCategory = currentCategory === 'all' || getDirCategory(dirPath) === currentCategory;
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) {
if (filteredChildren.length > 0) {
return { ...item, children: filteredChildren };
}
if (searchTerm) return nameMatch ? { ...item, children: [] } : null;
if (currentCategory === 'all') return { ...item, children: [] };
if (dirMatchesCategory) return { ...item, children: [] };
return null;
}
@@ -417,37 +424,44 @@ function filterDataForTree() {
.filter(Boolean);
}
return filterItems(fileData, '', true);
return filterItems(fileData, '');
}
function renderTreeItems(items, level, path = '', expanded = false) {
function renderTreeItems(items, level, path = '', forceExpand = false) {
const frag = document.createDocumentFragment();
const sortedItems = sortTreeItems(items, path);
items.forEach((item, index) => {
sortedItems.forEach((item, index) => {
if (item.type === 'directory') {
const dirPath = `${path}${item.name}/`;
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const treeItem = el('div', { class: `tree-item${expanded ? ' expanded' : ''}` });
const expanded = forceExpand || expandedDirs.has(dirPath);
const treeItem = el('div', { class: `loot-tree-node${expanded ? ' expanded' : ''}` });
treeItem.style.animationDelay = `${index * 0.05}s`;
treeItem.style.setProperty('--loot-level', String(level));
const header = el('div', { class: 'tree-header' }, [
el('div', { class: 'tree-icon folder-icon' }, ['📁']),
el('div', { class: 'tree-name' }, [item.name]),
const stats = directoryStats(item);
const header = el('button', { class: 'loot-tree-row', type: 'button' }, [
el('span', { class: 'loot-tree-chevron' }, [hasChildren ? '▶' : '•']),
el('span', { class: 'loot-tree-icon folder-icon' }, ['📁']),
el('span', { class: 'loot-tree-name' }, [item.name]),
el('span', { class: 'loot-tree-meta' }, [t('loot.filesCount', { count: stats.files })]),
]);
if (hasChildren) {
header.appendChild(el('div', { class: 'tree-chevron' }, ['▶']));
}
tracker.trackEventListener(header, 'click', (e) => {
e.stopPropagation();
treeItem.classList.toggle('expanded');
if (!hasChildren) return;
const next = !treeItem.classList.contains('expanded');
treeItem.classList.toggle('expanded', next);
if (next) expandedDirs.add(dirPath);
else expandedDirs.delete(dirPath);
});
treeItem.appendChild(header);
if (hasChildren) {
const children = el('div', { class: 'tree-children' });
children.appendChild(renderTreeItems(item.children, level + 1, `${path}${item.name}/`, expanded));
const children = el('div', { class: 'loot-tree-children' });
children.appendChild(renderTreeItems(item.children, level + 1, dirPath, forceExpand));
treeItem.appendChild(children);
}
@@ -462,7 +476,7 @@ function renderTreeItems(items, level, path = '', expanded = false) {
category,
fullPath: `${path}${item.name}`,
path: item.path || `${path}${item.name}`,
}, category, index, false));
}, category, index, false, { treeLevel: level + 1, treeMode: true }));
}
});
@@ -522,10 +536,13 @@ function fileTimestamp(file) {
return 0;
}
function renderFileItem(file, category, index = 0, showPath = false) {
function renderFileItem(file, category, index = 0, showPath = false, opts = {}) {
const path = file.path || file.fullPath || file.name;
const item = el('div', { class: 'file-item', 'data-path': path });
const item = el('div', { class: `file-item${opts.treeMode ? ' is-tree-file' : ''}`, 'data-path': path });
item.style.animationDelay = `${index * 0.02}s`;
if (typeof opts.treeLevel === 'number') {
item.style.setProperty('--loot-level', String(opts.treeLevel));
}
tracker.trackEventListener(item, 'click', () => {
downloadFile(path);
@@ -544,6 +561,55 @@ function renderFileItem(file, category, index = 0, showPath = false) {
return item;
}
function compareBySort(a, b, path = '') {
let res = 0;
switch (currentSort) {
case 'type': {
const ca = a.type === 'directory' ? getDirCategory(`${path}${a.name}/`) : getFileCategory(a.name, path);
const cb = b.type === 'directory' ? getDirCategory(`${path}${b.name}/`) : getFileCategory(b.name, path);
res = ca.localeCompare(cb) || String(a.name || '').localeCompare(String(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;
}
function sortTreeItems(items, path = '') {
return [...(items || [])].sort((a, b) => {
const ad = a.type === 'directory';
const bd = b.type === 'directory';
if (ad !== bd) return ad ? -1 : 1;
return compareBySort(a, b, path);
});
}
function expandRootDirectories(items) {
(items || []).forEach((item) => {
if (item.type === 'directory') {
expandedDirs.add(`${item.name}/`);
}
});
}
function directoryStats(item) {
let files = 0;
const walk = (nodes) => {
for (const n of nodes || []) {
if (n.type === 'directory') walk(n.children || []);
if (n.type === 'file') files += 1;
}
};
walk(item.children || []);
return { files };
}
function downloadFile(path) {
window.location.href = `/loot_download?path=${encodeURIComponent(path)}`;
}

View File

@@ -24,6 +24,9 @@ let sortOrder = 1;
let currentFilter = null;
let searchTerm = '';
let searchDebounce = null;
let prevCardKeys = []; /* track card order for incremental DOM */
let prevFingerprints = {}; /* track card content for change detection */
let prevDataFingerprint = ''; /* track raw data to skip unnecessary work */
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
@@ -65,21 +68,35 @@ export async function mount(container) {
}
export function unmount() {
clearTimeout(searchDebounce);
if (searchDebounce != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounce);
else clearTimeout(searchDebounce);
searchDebounce = null;
}
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
originalData = [];
searchTerm = '';
currentFilter = null;
prevCardKeys = [];
prevFingerprints = {};
prevDataFingerprint = '';
}
/* ── data fetch ── */
async function refresh() {
try {
const data = await api.get('/netkb_data', { timeout: 8000 });
originalData = Array.isArray(data) ? data : [];
if (!tracker) return; /* unmounted while awaiting */
const newData = Array.isArray(data) ? data : [];
/* Skip full refresh if data unchanged */
const fp = newData.map(d => `${d.mac}|${d.ip}|${d.hostname}|${d.alive}|${(d.ports||[]).join(',')}|${(d.actions||[]).map(a=>`${a?.name}:${a?.status}`).join(',')}`).join(';');
if (fp === prevDataFingerprint) return;
prevDataFingerprint = fp;
originalData = newData;
refreshDisplay();
} catch (err) {
if (err.name === 'AbortError') return;
console.warn(`[${PAGE}]`, err.message);
}
}
@@ -143,13 +160,18 @@ function toggleSearchPop() {
}
function onSearchInput(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
if (searchDebounce != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounce);
else clearTimeout(searchDebounce);
}
const handler = () => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('netkb:search', e.target.value.trim());
refreshDisplay();
syncClearBtn();
}, 120);
searchDebounce = null;
};
searchDebounce = tracker ? tracker.trackTimeout(handler, 120) : setTimeout(handler, 120);
}
function clearSearch() {
@@ -264,45 +286,92 @@ function matchesSearch(item) {
return false;
}
/* ── card rendering ── */
/* ── card key + fingerprint for incremental updates ── */
function cardKey(item) {
return `${item.mac || ''}_${item.ip || ''}`;
}
function cardFingerprint(item) {
const ports = (item.ports || []).filter(Boolean).join(',');
const acts = (item.actions || []).map(a => `${a?.name}:${a?.status}`).join(',');
return `${item.hostname}|${item.ip}|${item.mac}|${item.vendor}|${item.essid}|${item.alive}|${ports}|${acts}`;
}
/* ── build a single card DOM ── */
function buildCardEl(item) {
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(t('netkb.ip'), 'ip', item.ip));
if (item.mac) sections.push(fieldRow(t('netkb.mac'), 'mac', item.mac));
if (item.vendor && item.vendor !== 'N/A') sections.push(fieldRow(t('netkb.vendor'), 'vendor', item.vendor));
if (item.essid && item.essid !== 'N/A') sections.push(fieldRow(t('netkb.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)))
),
]));
}
const card = el('div', { class: cardClass, 'data-card-key': cardKey(item) }, [
el('div', { class: 'card-content' }, [
el('h3', { class: 'card-title' }, [hlText(title)]),
...sections,
]),
el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip)),
]);
return card;
}
/* ── card rendering (incremental) ── */
function renderCards(data) {
const container = $('#netkb-card-container');
if (!container) return;
empty(container);
const visible = data.filter(i => showNotAlive || i.alive);
/* empty state */
if (visible.length === 0) {
container.appendChild(el('div', { class: 'netkb-empty' }, [t('common.noData')]));
if (prevCardKeys.length > 0 || container.children.length === 0 ||
!container.querySelector('.netkb-empty')) {
empty(container);
container.appendChild(el('div', { class: 'netkb-empty' }, [t('common.noData')]));
prevCardKeys = [];
prevFingerprints = {};
}
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');
/* compute new keys + fingerprints */
const newKeys = visible.map(cardKey);
const newFP = {};
visible.forEach(item => { newFP[cardKey(item)] = cardFingerprint(item); });
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)))
),
]));
/* first render or structural change (different keys/order) → full rebuild */
const keysMatch = newKeys.length === prevCardKeys.length &&
newKeys.every((k, i) => k === prevCardKeys[i]);
if (!keysMatch) {
/* full rebuild — order or set of items changed */
empty(container);
for (const item of visible) container.appendChild(buildCardEl(item));
} else {
/* incremental — only replace cards whose fingerprint changed */
const children = container.children;
for (let i = 0; i < visible.length; i++) {
const key = newKeys[i];
if (newFP[key] !== prevFingerprints[key]) {
const newCard = buildCardEl(visible[i]);
container.replaceChild(newCard, children[i]);
}
}
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)),
]));
}
prevCardKeys = newKeys;
prevFingerprints = newFP;
}
/* ── table rendering ── */
@@ -317,15 +386,15 @@ function renderTable(data) {
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('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('toggleAlive'), title: t('netkb.toggleOffline'), alt: 'Filter' })]),
el('th', { onclick: thClick('ip') }, [t('netkb.ip')]),
el('th', { onclick: thClick('mac') }, [t('netkb.mac')]),
el('th', { onclick: thClick('essid') }, [t('netkb.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('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasPorts'), title: t('netkb.hasPorts'), 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' })]),
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasActions'), title: t('netkb.hasActions'), alt: 'Filter' })]),
]),
]);
@@ -334,10 +403,10 @@ function renderTable(data) {
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', {}, item.ip ? [chip('ip', item.ip)] : [t('netkb.na')]),
el('td', {}, item.mac ? [chip('mac', item.mac)] : [t('netkb.na')]),
el('td', {}, (item.essid && item.essid !== 'N/A') ? [chip('essid', item.essid)] : [t('netkb.na')]),
el('td', {}, (item.vendor && item.vendor !== 'N/A') ? [chip('vendor', item.vendor)] : [t('netkb.na')]),
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))]),
@@ -372,7 +441,7 @@ function renderBadges(actions, ip) {
}
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;
const label = s => ({ success: t('netkb.success'), failed: t('netkb.failed'), fail: t('netkb.failed'), running: t('netkb.running'), pending: t('netkb.pending'), expired: t('netkb.expired'), cancelled: t('netkb.cancelled') })[s] || s;
return Array.from(map.values())
.sort((a, b) => b.parsed.ts - a.parsed.ts)
@@ -392,7 +461,7 @@ function renderBadges(actions, ip) {
}, [
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}`])]),
el('div', { class: 'badge-timestamp' }, [el('div', {}, [date]), el('div', {}, [`${t('netkb.at')} ${time}`])]),
]);
});
}

View File

@@ -30,6 +30,8 @@ let showLabels = true;
let searchTerm = '';
let searchDebounce = null;
let currentSortState = { column: -1, direction: 'asc' };
let prevNetworkFingerprint = '';
let stickyLevel = 0; /* 0=off, 1=col1, 2=col1+2, 3=col1+2 (max for 2-col table) */
/* D3 state */
let d3Module = null;
@@ -53,6 +55,7 @@ export async function mount(container) {
tracker = new ResourceTracker(PAGE);
viewMode = getPref('nv:view', 'table');
if (!['table', 'map'].includes(viewMode)) viewMode = 'table';
showLabels = getPref('nv:showHostname', 'true') === 'true';
const savedSearch = getPref('nv:search', '');
if (savedSearch) searchTerm = savedSearch.toLowerCase();
@@ -68,6 +71,7 @@ export async function mount(container) {
export function unmount() {
clearTimeout(searchDebounce);
searchDebounce = null;
if (poller) { poller.stop(); poller = null; }
if (simulation) { simulation.stop(); simulation = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
@@ -81,17 +85,25 @@ export function unmount() {
nodeGroup = null;
linkGroup = null;
labelsGroup = null;
currentSortState = { column: -1, direction: 'asc' };
searchTerm = '';
prevNetworkFingerprint = '';
}
/* ── data fetch ── */
async function refresh() {
try {
const html = await api.get('/network_data', { timeout: 8000 });
if (typeof html !== 'string') return;
networkData = parseNetworkHTML(html);
if (typeof html !== 'string' || !tracker) return;
const parsed = parseNetworkHTML(html);
/* Skip DOM rebuild when data unchanged */
const fp = parsed.map(r => `${r.hostname}|${r.ip}|${r.mac}|${(r.ports||[]).join(',')}`).join(';');
if (fp === prevNetworkFingerprint) return;
prevNetworkFingerprint = fp;
networkData = parsed;
renderTable();
applySearchToTable();
if (mapInitialized) updateMapFromData(networkData);
if (mapInitialized && simulation) updateMapFromData(networkData);
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
@@ -135,7 +147,7 @@ function buildShell(savedSearch) {
}),
el('button', {
class: 'nv-search-clear', id: 'nv-searchClear', type: 'button',
'aria-label': 'Clear', onclick: clearSearch
'aria-label': t('common.clear'), onclick: clearSearch
}, ['\u2715']),
]),
el('div', { class: 'segmented', id: 'viewSeg' }, [
@@ -168,13 +180,21 @@ function buildShell(savedSearch) {
/* ── search ── */
function onSearchInput(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('nv:search', searchTerm);
applySearchToTable();
applySearchToMap();
syncClearBtn();
}, 120);
searchDebounce = tracker
? tracker.trackTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('nv:search', searchTerm);
applySearchToTable();
applySearchToMap();
syncClearBtn();
}, 120)
: setTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('nv:search', searchTerm);
applySearchToTable();
applySearchToMap();
syncClearBtn();
}, 120);
}
function clearSearch() {
@@ -212,6 +232,7 @@ function applySearchToMap() {
/* ── view ── */
function setView(mode) {
if (!['table', 'map'].includes(mode)) return;
viewMode = mode;
setPref('nv:view', mode);
syncViewUI();
@@ -219,9 +240,14 @@ function setView(mode) {
}
function syncViewUI() {
const root = $('.network-container');
const tableWrap = $('#table-wrap');
const mapContainer = $('#visualization-container');
const hostSwitch = $('#hostSwitch');
if (root) {
root.classList.toggle('is-table-view', viewMode === 'table');
root.classList.toggle('is-map-view', viewMode === 'map');
}
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';
@@ -250,10 +276,20 @@ function renderTable() {
return;
}
const pinBtn = el('button', {
class: 'nv-pin-btn',
title: L('network.toggleSticky', 'Pin columns'),
onclick: () => cycleStickyLevel(),
}, ['\uD83D\uDCCC']);
if (stickyLevel > 0) pinBtn.classList.add('active');
const thead = el('thead', {}, [
el('tr', {}, [
el('th', { class: 'hosts-header' }, [L('common.hosts', 'Hosts')]),
el('th', {}, [L('common.ports', 'Ports')]),
el('th', { class: 'hosts-header' }, [
L('common.hosts', 'Hosts'),
el('span', { style: 'display:inline-flex;margin-left:6px;vertical-align:middle' }, [pinBtn]),
]),
el('th', { class: 'ports-header' }, [L('common.ports', 'Ports')]),
]),
]);
@@ -264,20 +300,70 @@ function renderTable() {
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]));
if (hostBubbles.length === 0) hostBubbles.push(el('span', { class: 'bubble bubble-empty' }, [t('network.unknownHost')]));
const portBubbles = item.ports.map(p => el('span', { class: 'port-bubble' }, [p]));
const portBubbles = item.ports.length
? item.ports.map(p => el('span', { class: 'port-bubble' }, [p]))
: [el('span', { class: 'port-bubble is-empty' }, [L('common.none', 'None')])];
return el('tr', {}, [
el('td', { class: 'hosts-cell' }, [el('div', { class: 'hosts-content' }, hostBubbles)]),
el('td', {}, [el('div', { class: 'ports-container' }, portBubbles)]),
el('td', { class: 'ports-cell' }, [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]));
applyStickyClasses();
/* table sort */
initTableSorting(table);
applyCurrentSort(table);
}
function cycleStickyLevel() {
stickyLevel = (stickyLevel + 1) % 3; /* 0 → 1 → 2 → 0 */
applyStickyClasses();
}
function applyStickyClasses() {
const table = document.querySelector('#network-table table');
if (!table) return;
const headers = table.querySelectorAll('thead th');
const rows = table.querySelectorAll('tbody tr');
/* update pin button state */
const pinBtn = table.querySelector('.nv-pin-btn');
if (pinBtn) {
pinBtn.classList.toggle('active', stickyLevel > 0);
pinBtn.textContent = stickyLevel > 0 ? `\uD83D\uDCCC${stickyLevel}` : '\uD83D\uDCCC';
}
/* reset all sticky */
headers.forEach(th => { th.classList.remove('nv-sticky-col'); th.style.left = ''; });
rows.forEach(tr => {
tr.querySelectorAll('td').forEach(td => { td.classList.remove('nv-sticky-col'); td.style.left = ''; });
});
if (stickyLevel === 0) return;
/* measure column widths */
const firstRow = table.querySelector('tbody tr');
if (!firstRow) return;
const cells = firstRow.querySelectorAll('td');
let leftOffset = 0;
for (let col = 0; col < Math.min(stickyLevel, headers.length); col++) {
const th = headers[col];
const w = cells[col] ? cells[col].offsetWidth : th.offsetWidth;
th.classList.add('nv-sticky-col');
th.style.left = leftOffset + 'px';
rows.forEach(tr => {
const td = tr.children[col];
if (td) { td.classList.add('nv-sticky-col'); td.style.left = leftOffset + 'px'; }
});
leftOffset += w;
}
}
function initTableSorting(table) {
@@ -293,18 +379,33 @@ function initTableSorting(table) {
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));
sortTable(table, idx, currentSortState.direction);
});
});
}
function applyCurrentSort(table) {
if (!table) return;
if (currentSortState.column < 0) return;
const headers = Array.from(table.querySelectorAll('th'));
headers.forEach(x => x.classList.remove('sort-asc', 'sort-desc'));
const active = headers[currentSortState.column];
if (active) active.classList.add(`sort-${currentSortState.direction}`);
sortTable(table, currentSortState.column, currentSortState.direction);
}
function sortTable(table, colIndex, direction) {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const A = a.querySelectorAll('td')[colIndex]?.textContent.trim().toLowerCase() || '';
const B = b.querySelectorAll('td')[colIndex]?.textContent.trim().toLowerCase() || '';
return direction === 'asc' ? A.localeCompare(B) : B.localeCompare(A);
});
rows.forEach(r => tbody.appendChild(r));
}
/* ── D3 Map ── */
async function initMap() {
const container = $('#visualization-container');
@@ -321,7 +422,7 @@ async function initMap() {
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.']));
container.appendChild(el('div', { class: 'network-empty' }, [t('network.d3Unavailable')]));
return;
}
}
@@ -420,6 +521,7 @@ function updateMapFromData(data) {
incomingNodes.set('bjorn', { id: 'bjorn', type: 'bjorn', r: 50, label: 'BJORN' });
data.forEach(h => {
if (!h?.ip) return;
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');
@@ -556,7 +658,7 @@ function showTooltip(e, d) {
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]));
tt.appendChild(el('div', { style: 'color:var(--acid);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]));
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,12 @@ 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')
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 ── */
@@ -33,6 +33,8 @@ let lastBuckets = null;
let showCount = null;
let lastFilterKey = '';
let iconCache = new Map();
/** Map<lane, Map<cardKey, DOM element>> for incremental updates */
let laneCardMaps = new Map();
/* ── lifecycle ── */
export async function mount(container) {
@@ -46,12 +48,18 @@ export async function mount(container) {
}
export function unmount() {
clearTimeout(searchDeb);
searchDeb = null;
if (poller) { poller.stop(); poller = null; }
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
lastBuckets = null;
showCount = null;
lastFilterKey = '';
iconCache.clear();
laneCardMaps.clear();
LIVE = true; FOCUS = false; COMPACT = false;
COLLAPSED = false; INCLUDE_SUPERSEDED = false;
}
/* ── shell ── */
@@ -60,15 +68,15 @@ function buildShell() {
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...)',
type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'),
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),
pill('sched-focBtn', t('sched.focusActive'), false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }),
pill('sched-cmpBtn', t('sched.compact'), false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }),
pill('sched-colBtn', t('sched.collapse'), false, toggleCollapse),
pill('sched-supBtn', INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded'), false, toggleSuperseded),
el('span', { id: 'sched-stats', class: 'stats' }),
]),
el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [
@@ -88,7 +96,7 @@ function buildShell() {
]),
el('div', { id: 'sched-histBody', class: 'modalBody' }),
el('div', { class: 'modalFooter' }, [
el('small', {}, ['Rows are color-coded by status.']),
el('small', {}, [t('sched.historyColorCoded')]),
]),
]),
]),
@@ -134,7 +142,7 @@ async function tick() {
const rows = await fetchQueue();
render(rows);
} catch (e) {
showError('Queue fetch error: ' + e.message);
showError(t('sched.fetchError') + ': ' + e.message);
}
}
@@ -203,7 +211,7 @@ function render(rows) {
/* 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}`;
if (statsEl) statsEl.textContent = `${total} ${t('sched.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);
@@ -214,43 +222,162 @@ function render(rows) {
renderBoard(buckets);
}
/* ── cardKey: stable identifier for a card row ── */
function cardKey(r) {
return `${r.id || ''}|${r.action_name}|${r.mac}|${r.port || 0}|${r._computed_status}`;
}
/* ── card fingerprint for detecting data changes ── */
function cardFingerprint(r) {
return `${r.status}|${r.retry_count || 0}|${r.priority_effective}|${r.started_at || ''}|${r.completed_at || ''}|${r.error_message || ''}|${r.result_summary || ''}|${(r.tags || []).join(',')}`;
}
/**
* Incremental board rendering — updates DOM in-place instead of destroying/recreating.
* This prevents flickering of countdown timers and progress bars.
*/
function renderBoard(buckets) {
const board = $('#sched-board');
if (!board) return;
empty(board);
/* First render: build full structure */
if (!board.children.length) {
laneCardMaps.clear();
LANES.forEach(lane => {
const items = buckets[lane] || [];
const visible = items.slice(0, showCount?.[lane] || PAGE_SIZE);
const cardMap = new Map();
laneCardMaps.set(lane, cardMap);
const laneBody = el('div', { class: 'laneBody' });
if (visible.length === 0) {
laneBody.appendChild(el('div', { class: 'empty' }, [t('sched.noEntries')]));
} else {
visible.forEach(r => {
const card = cardEl(r);
card.dataset.cardKey = cardKey(r);
card.dataset.fp = cardFingerprint(r);
cardMap.set(cardKey(r), card);
laneBody.appendChild(card);
});
if (items.length > visible.length) {
laneBody.appendChild(moreBtn(lane));
}
}
const laneEl = el('div', { class: `lane status-${lane}`, 'data-lane': lane }, [
el('div', { class: 'laneHeader' }, [
el('span', { class: 'dot' }),
el('strong', {}, [LANE_LABELS[lane]()]),
el('span', { class: 'count' }, [String(items.length)]),
]),
laneBody,
]);
board.appendChild(laneEl);
});
if (COLLAPSED) $$('.card', board).forEach(c => c.classList.add('collapsed'));
startClock();
return;
}
/* Incremental update: patch each lane in-place */
LANES.forEach(lane => {
const items = buckets[lane] || [];
const visible = items.slice(0, showCount?.[lane] || PAGE_SIZE);
const hasMore = items.length > visible.length;
const laneEl = board.querySelector(`[data-lane="${lane}"]`);
if (!laneEl) return;
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'])] : []),
]
),
]);
/* Update header count */
const countEl = laneEl.querySelector('.laneHeader .count');
if (countEl) countEl.textContent = String(items.length);
board.appendChild(laneEl);
const laneBody = laneEl.querySelector('.laneBody');
if (!laneBody) return;
const oldMap = laneCardMaps.get(lane) || new Map();
const newMap = new Map();
const desiredKeys = visible.map(r => cardKey(r));
const desiredSet = new Set(desiredKeys);
/* Remove cards no longer present */
for (const [key, cardDom] of oldMap) {
if (!desiredSet.has(key)) {
cardDom.remove();
}
}
/* Remove "more" button and empty message (will re-add if needed) */
laneBody.querySelectorAll('.moreBtn, .empty').forEach(n => n.remove());
/* Add/update cards in order */
let prevNode = null;
for (let i = 0; i < visible.length; i++) {
const r = visible[i];
const key = cardKey(r);
const fp = cardFingerprint(r);
let cardDom = oldMap.get(key);
if (cardDom) {
/* Card exists - check if data changed */
if (cardDom.dataset.fp !== fp) {
/* Data changed - replace with fresh card */
const newCard = cardEl(r);
newCard.dataset.cardKey = key;
newCard.dataset.fp = fp;
if (COLLAPSED) newCard.classList.add('collapsed');
cardDom.replaceWith(newCard);
cardDom = newCard;
}
newMap.set(key, cardDom);
} else {
/* New card */
cardDom = cardEl(r);
cardDom.dataset.cardKey = key;
cardDom.dataset.fp = fp;
if (COLLAPSED) cardDom.classList.add('collapsed');
newMap.set(key, cardDom);
}
/* Ensure correct order in DOM */
const expectedAfter = prevNode;
const actualPrev = cardDom.previousElementSibling;
if (actualPrev !== expectedAfter || !cardDom.parentNode) {
if (expectedAfter) {
expectedAfter.after(cardDom);
} else {
laneBody.prepend(cardDom);
}
}
prevNode = cardDom;
}
/* Empty state */
if (visible.length === 0) {
laneBody.appendChild(el('div', { class: 'empty' }, [t('sched.noEntries')]));
}
/* "More" button */
if (items.length > visible.length) {
laneBody.appendChild(moreBtn(lane));
}
laneCardMaps.set(lane, newMap);
});
if (COLLAPSED) $$('.card', board).forEach(c => c.classList.add('collapsed'));
startClock();
}
/* restart countdown clock */
function moreBtn(lane) {
return el('button', {
class: 'moreBtn', onclick: () => {
showCount[lane] = (showCount[lane] || PAGE_SIZE) + PAGE_SIZE;
if (lastBuckets) renderBoard(lastBuckets);
}
}, [t('sched.displayMore')]);
}
function startClock() {
if (clockTimer) clearInterval(clockTimer);
clockTimer = setInterval(updateCountdowns, 1000);
}
@@ -284,12 +411,12 @@ function cardEl(r) {
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.port) chips.push(chipEl(`${t('sched.port')} ${r.port}`, 210, t('sched.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}`])]));
if (r.service) children.push(el('div', { class: 'kv' }, [el('span', {}, [`${t('sched.service')}: ${r.service}`])]));
/* tags */
if (r.tags?.length) {
@@ -300,33 +427,33 @@ function cardEl(r) {
/* 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' }, ['-']),
t('sched.eligibleIn') + ' ', 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' }, ['-']),
t('sched.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)}`]));
const meta = [el('span', {}, [`${t('sched.created')}: ${fmt(r.created_at)}`])];
if (r.started_at) meta.push(el('span', {}, [`${t('sched.started')}: ${fmt(r.started_at)}`]));
if (r.completed_at) meta.push(el('span', {}, [`${t('sched.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}`]));
`${t('sched.retries')} ${r.retry_count}${r.max_retries != null ? '/' + r.max_retries : ''}`]));
if (r.priority_effective) meta.push(el('span', {}, [`${t('sched.priority')}: ${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']));
btns.push(el('button', { class: 'btn warn', onclick: () => queueCmd(r.id, 'cancel') }, [t('common.cancel')]));
}
if (!['running', 'pending', 'scheduled'].includes(r.status)) {
btns.push(el('button', { class: 'btn danger', onclick: () => queueCmd(r.id, 'delete') }, ['Delete']));
btns.push(el('button', { class: 'btn danger', onclick: () => queueCmd(r.id, 'delete') }, [t('common.delete')]));
}
if (btns.length) children.push(el('div', { class: 'btns' }, btns));
@@ -354,7 +481,7 @@ function updateCountdowns() {
if (!cd || !ts) return;
if (type === 'start') {
const diff = ts - now;
cd.textContent = diff <= 0 ? 'due' : ms2str(diff);
cd.textContent = diff <= 0 ? t('sched.due') : ms2str(diff);
} else if (type === 'elapsed') {
cd.textContent = ms2str(now - ts);
}
@@ -374,7 +501,7 @@ async function queueCmd(id, cmd) {
await api.post('/queue_cmd', { id, cmd });
tick();
} catch (e) {
showError('Command failed: ' + e.message);
showError(t('sched.cmdFailed') + ': ' + e.message);
}
}
@@ -387,7 +514,7 @@ async function openHistory(action, mac, port) {
if (title) title.textContent = `\u2014 ${action} \u00B7 ${mac}${port && port !== 0 ? ` \u00B7 port ${port}` : ''}`;
empty(body);
body.appendChild(el('div', { class: 'empty' }, ['Loading\u2026']));
body.appendChild(el('div', { class: 'empty' }, [t('common.loading')]));
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
@@ -398,7 +525,7 @@ async function openHistory(action, mac, port) {
empty(body);
if (!rows.length) {
body.appendChild(el('div', { class: 'empty' }, ['No history']));
body.appendChild(el('div', { class: 'empty' }, [t('sched.noHistory')]));
return;
}
@@ -412,7 +539,7 @@ async function openHistory(action, mac, port) {
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 : ''}`])
? el('span', { style: 'color:var(--ink)' }, [`${t('sched.retry')} ${hr.retry_count}${hr.max_retries != null ? '/' + hr.max_retries : ''}`])
: null;
body.appendChild(el('div', { class: `histRow hist-${st}` }, [
@@ -424,7 +551,7 @@ async function openHistory(action, mac, port) {
});
} catch (e) {
empty(body);
body.appendChild(el('div', { class: 'empty' }, [`Error: ${e.message}`]));
body.appendChild(el('div', { class: 'empty' }, [`${t('common.error')}: ${e.message}`]));
}
}
@@ -450,7 +577,7 @@ function setLive(on) {
function toggleCollapse() {
COLLAPSED = !COLLAPSED;
const btn = $('#sched-colBtn');
if (btn) btn.textContent = COLLAPSED ? 'Expand' : 'Collapse';
if (btn) btn.textContent = COLLAPSED ? t('sched.expand') : t('sched.collapse');
$$('#sched-board .card').forEach(c => c.classList.toggle('collapsed', COLLAPSED));
}
@@ -459,7 +586,7 @@ function toggleSuperseded() {
const btn = $('#sched-supBtn');
if (btn) {
btn.classList.toggle('active', INCLUDE_SUPERSEDED);
btn.textContent = INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded';
btn.textContent = INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded');
}
lastFilterKey = '';
tick();
@@ -482,7 +609,6 @@ function showError(msg) {
/* ── 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';
}

752
web/js/pages/sentinel.js Normal file
View File

@@ -0,0 +1,752 @@
/**
* Sentinel Watchdog — SPA page
* Real-time network monitoring, event feed, rules engine, device baselines.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast, escapeHtml, confirmT } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'sentinel';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let poller = null;
let root = null;
let sentinelEnabled = false;
let events = [];
let rules = [];
let devices = [];
let unreadCount = 0;
let sideTab = 'rules'; // 'rules' | 'devices' | 'notifiers'
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await refresh();
poller = new Poller(refresh, 5000);
poller.start();
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
events = [];
rules = [];
devices = [];
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'sentinel-page' }, [
/* ── Header ───────────────────────────────────────── */
el('div', { class: 'sentinel-header' }, [
el('h1', { class: 'sentinel-title' }, [
el('span', { class: 'sentinel-title-icon' }, ['🛡️']),
el('span', { 'data-i18n': 'sentinel.title' }, [t('sentinel.title')]),
]),
el('div', { class: 'sentinel-controls' }, [
el('button', { class: 'sentinel-toggle', id: 'sentinel-toggle' }, [
el('span', { class: 'dot' }),
el('span', { class: 'sentinel-toggle-label', 'data-i18n': 'sentinel.disabled' }, [t('sentinel.disabled')]),
]),
]),
]),
/* ── Stats bar ────────────────────────────────────── */
el('div', { class: 'sentinel-stats', id: 'sentinel-stats' }),
/* ── Main grid ────────────────────────────────────── */
el('div', { class: 'sentinel-grid' }, [
/* Left: event feed */
el('div', { class: 'sentinel-panel' }, [
el('div', { class: 'sentinel-panel-head' }, [
el('span', { 'data-i18n': 'sentinel.eventFeed' }, [t('sentinel.eventFeed')]),
el('div', { style: 'display:flex;gap:6px' }, [
el('button', {
class: 'sentinel-toggle', id: 'sentinel-ack-all',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('sentinel.ackAll')]),
el('button', {
class: 'sentinel-toggle', id: 'sentinel-clear',
style: 'padding:3px 8px;font-size:0.65rem',
}, [t('sentinel.clearAll')]),
]),
]),
el('div', { class: 'sentinel-panel-body', id: 'sentinel-events' }, [
el('div', { style: 'color:var(--muted);text-align:center;padding:40px 10px;font-size:0.8rem' },
[t('common.loading')]),
]),
]),
/* Right: sidebar */
el('div', { class: 'sentinel-panel' }, [
el('div', { class: 'sentinel-side-tabs' }, [
sideTabBtn('rules', t('sentinel.rules')),
sideTabBtn('devices', t('sentinel.devices')),
sideTabBtn('notifiers', t('sentinel.notifiers')),
]),
el('div', { class: 'sentinel-panel-body', id: 'sentinel-sidebar' }),
]),
]),
]);
}
function sideTabBtn(id, label) {
return el('button', {
class: `sentinel-side-tab${sideTab === id ? ' active' : ''}`,
'data-stab': id,
}, [label]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
// Toggle sentinel on/off
root.addEventListener('click', async (e) => {
const toggle = e.target.closest('#sentinel-toggle');
if (toggle) {
try {
const res = await api.post('/api/sentinel/toggle', { enabled: !sentinelEnabled });
sentinelEnabled = res.enabled;
paintToggle();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Ack all
if (e.target.closest('#sentinel-ack-all')) {
try {
await api.post('/api/sentinel/ack', { all: true });
toast(t('sentinel.allAcked'), 2000, 'success');
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Clear all
if (e.target.closest('#sentinel-clear')) {
if (!confirmT(t('sentinel.confirmClear'))) return;
try {
await api.post('/api/sentinel/clear', {});
toast(t('sentinel.eventsCleared'), 2000, 'success');
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Side tab switch
const stab = e.target.closest('[data-stab]');
if (stab) {
sideTab = stab.dataset.stab;
$$('.sentinel-side-tab', root).forEach(b =>
b.classList.toggle('active', b.dataset.stab === sideTab));
paintSidebar();
return;
}
// Ack single event
const ackBtn = e.target.closest('[data-ack]');
if (ackBtn) {
try {
await api.post('/api/sentinel/ack', { id: parseInt(ackBtn.dataset.ack) });
await refreshEvents();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Toggle rule enabled
const ruleToggle = e.target.closest('[data-rule-toggle]');
if (ruleToggle) {
const ruleId = parseInt(ruleToggle.dataset.ruleToggle);
const rule = rules.find(r => r.id === ruleId);
if (rule) {
try {
await api.post('/api/sentinel/rule', { id: ruleId, name: rule.name, trigger_type: rule.trigger_type, enabled: rule.enabled ? 0 : 1 });
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
}
return;
}
// Delete rule
const ruleDel = e.target.closest('[data-rule-del]');
if (ruleDel) {
if (!confirmT(t('sentinel.confirmDeleteRule'))) return;
try {
await api.post('/api/sentinel/rule/delete', { id: parseInt(ruleDel.dataset.ruleDel) });
toast(t('sentinel.ruleDeleted'), 2000, 'success');
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
return;
}
// Add rule
if (e.target.closest('#sentinel-add-rule')) {
showRuleEditor();
return;
}
// Edit rule
const ruleEdit = e.target.closest('[data-rule-edit]');
if (ruleEdit) {
const ruleId = parseInt(ruleEdit.dataset.ruleEdit);
const rule = rules.find(r => r.id === ruleId);
if (rule) showRuleEditor(rule);
return;
}
// Save notifiers
if (e.target.closest('#sentinel-save-notifiers')) {
saveNotifiers();
return;
}
// Save device
const devSave = e.target.closest('[data-dev-save]');
if (devSave) {
saveDevice(devSave.dataset.devSave);
return;
}
});
}
/* ── Data refresh ──────────────────────────────────────── */
async function refresh() {
try {
const [statusData, eventsData, rulesData, devicesData] = await Promise.all([
api.get('/api/sentinel/status'),
api.get('/api/sentinel/events?limit=100'),
api.get('/api/sentinel/rules'),
api.get('/api/sentinel/devices'),
]);
sentinelEnabled = statusData.enabled;
events = eventsData.events || [];
unreadCount = eventsData.unread_count || 0;
rules = rulesData.rules || [];
devices = devicesData.devices || [];
paint();
} catch (err) {
console.warn('[sentinel] refresh error:', err.message);
}
}
async function refreshEvents() {
try {
const data = await api.get('/api/sentinel/events?limit=100');
events = data.events || [];
unreadCount = data.unread_count || 0;
paintStats();
paintEvents();
} catch (err) { console.warn('[sentinel] events error:', err.message); }
}
async function refreshRules() {
try {
const data = await api.get('/api/sentinel/rules');
rules = data.rules || [];
paintSidebar();
} catch (err) { console.warn('[sentinel] rules error:', err.message); }
}
/* ── Paint ─────────────────────────────────────────────── */
function paint() {
paintToggle();
paintStats();
paintEvents();
paintSidebar();
}
function paintToggle() {
const btn = $('#sentinel-toggle', root);
if (!btn) return;
btn.classList.toggle('active', sentinelEnabled);
const lbl = $('.sentinel-toggle-label', btn);
if (lbl) {
const key = sentinelEnabled ? 'sentinel.enabled' : 'sentinel.disabled';
lbl.textContent = t(key);
lbl.setAttribute('data-i18n', key);
}
}
function paintStats() {
const container = $('#sentinel-stats', root);
if (!container) return;
const alive = devices.filter(d => {
if (!d.last_seen) return false;
const diff = Date.now() - new Date(d.last_seen + 'Z').getTime();
return diff < 600000; // 10 min
}).length;
const stats = [
{ val: devices.length, lbl: t('sentinel.statDevices') },
{ val: alive, lbl: t('sentinel.statAlive') },
{ val: unreadCount, lbl: t('sentinel.statUnread') },
{ val: events.length, lbl: t('sentinel.statEvents') },
{ val: rules.filter(r => r.enabled).length, lbl: t('sentinel.statRules') },
];
empty(container);
for (const s of stats) {
container.appendChild(
el('div', { class: 'sentinel-stat' }, [
el('div', { class: 'sentinel-stat-val' }, [String(s.val)]),
el('div', { class: 'sentinel-stat-lbl' }, [s.lbl]),
])
);
}
}
function paintEvents() {
const container = $('#sentinel-events', root);
if (!container) return;
empty(container);
if (events.length === 0) {
container.appendChild(
el('div', {
style: 'color:var(--muted);text-align:center;padding:40px 10px;font-size:0.8rem'
}, [t('sentinel.noEvents')])
);
return;
}
for (const ev of events) {
const isUnread = !ev.acknowledged;
const sevClass = ev.severity === 'critical' ? ' sev-critical'
: ev.severity === 'warning' ? ' sev-warning' : '';
const card = el('div', {
class: `sentinel-event${isUnread ? ' unread' : ''}${sevClass}`,
}, [
el('div', { class: 'sentinel-event-head' }, [
el('div', { style: 'display:flex;align-items:center;flex:1;gap:6px;min-width:0' }, [
el('span', {
class: `sentinel-event-badge ${ev.event_type}`,
}, [formatEventType(ev.event_type)]),
el('span', { class: 'sentinel-event-title' }, [escapeHtml(ev.title)]),
]),
el('div', { style: 'display:flex;align-items:center;gap:6px;flex-shrink:0' }, [
el('span', { class: 'sentinel-event-time' }, [formatTime(ev.timestamp)]),
...(isUnread ? [
el('button', {
class: 'sentinel-toggle',
'data-ack': ev.id,
style: 'padding:1px 6px;font-size:0.6rem',
title: t('sentinel.acknowledge'),
}, ['✓'])
] : []),
]),
]),
el('div', { class: 'sentinel-event-body' }, [
escapeHtml(ev.details || ''),
...(ev.mac_address ? [
el('span', { style: 'margin-left:6px;opacity:0.6;font-family:monospace' },
[ev.mac_address])
] : []),
...(ev.ip_address ? [
el('span', { style: 'margin-left:4px;opacity:0.6;font-family:monospace' },
[ev.ip_address])
] : []),
]),
]);
container.appendChild(card);
}
}
/* ── Sidebar panels ────────────────────────────────────── */
function paintSidebar() {
const container = $('#sentinel-sidebar', root);
if (!container) return;
empty(container);
switch (sideTab) {
case 'rules': paintRules(container); break;
case 'devices': paintDevices(container); break;
case 'notifiers': paintNotifiers(container); break;
}
}
/* ── Rules ─────────────────────────────────────────────── */
function paintRules(container) {
// Add rule button
container.appendChild(
el('button', {
class: 'sentinel-toggle', id: 'sentinel-add-rule',
style: 'align-self:flex-start;margin-bottom:4px',
}, ['+ ' + t('sentinel.addRule')])
);
if (rules.length === 0) {
container.appendChild(
el('div', { style: 'color:var(--muted);text-align:center;padding:20px;font-size:0.75rem' },
[t('sentinel.noRules')])
);
return;
}
for (const rule of rules) {
let conditionsText = '';
try {
const conds = typeof rule.conditions === 'string' ? JSON.parse(rule.conditions) : rule.conditions;
conditionsText = Object.entries(conds || {}).map(([k, v]) => `${k}: ${v}`).join(', ');
} catch { conditionsText = ''; }
let actionsText = '';
try {
const acts = typeof rule.actions === 'string' ? JSON.parse(rule.actions) : rule.actions;
actionsText = (acts || []).join(', ');
} catch { actionsText = ''; }
container.appendChild(
el('div', { class: 'sentinel-rule' }, [
el('div', { class: 'sentinel-rule-info' }, [
el('div', { class: 'sentinel-rule-name' }, [
el('span', {
style: `color:${rule.enabled ? 'var(--acid)' : 'var(--muted)'}`,
}, [rule.enabled ? '● ' : '○ ']),
escapeHtml(rule.name),
]),
el('div', { class: 'sentinel-rule-type' }, [
rule.trigger_type,
conditionsText ? `${conditionsText}` : '',
]),
el('div', { class: 'sentinel-rule-type' }, [
`${t('sentinel.ruleLogic')}: ${rule.logic || 'AND'} · ${t('sentinel.ruleActions')}: ${actionsText}`,
]),
]),
el('div', { class: 'sentinel-rule-actions' }, [
el('button', {
class: 'sentinel-toggle',
'data-rule-toggle': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: rule.enabled ? t('sentinel.disable') : t('sentinel.enable'),
}, [rule.enabled ? '⏸' : '▶']),
el('button', {
class: 'sentinel-toggle',
'data-rule-edit': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: t('sentinel.editRule'),
}, ['✏️']),
el('button', {
class: 'sentinel-toggle',
'data-rule-del': rule.id,
style: 'padding:2px 6px;font-size:0.6rem',
title: t('sentinel.deleteRule'),
}, ['🗑']),
]),
])
);
}
}
/* ── Rule editor modal ─────────────────────────────────── */
const TRIGGER_TYPES = [
'new_device', 'device_join', 'device_leave',
'arp_spoof', 'port_change', 'mac_flood',
'rogue_dhcp', 'dns_anomaly',
];
const CONDITION_KEYS = [
'mac_contains', 'mac_not_contains',
'ip_prefix', 'ip_not_prefix',
'vendor_contains', 'min_new_devices', 'trusted_only',
];
const ACTION_TYPES = [
'notify_web', 'notify_discord', 'notify_webhook', 'notify_email',
];
function showRuleEditor(existing = null) {
const isEdit = !!existing;
let conditions = {};
let actions = ['notify_web'];
if (existing) {
try { conditions = typeof existing.conditions === 'string' ? JSON.parse(existing.conditions) : (existing.conditions || {}); } catch { conditions = {}; }
try { actions = typeof existing.actions === 'string' ? JSON.parse(existing.actions) : (existing.actions || ['notify_web']); } catch { actions = ['notify_web']; }
}
// Backdrop
const backdrop = el('div', {
class: 'sentinel-modal-backdrop',
style: 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100;display:flex;align-items:center;justify-content:center',
});
const modal = el('div', {
class: 'sentinel-modal',
style: 'background:var(--c-panel);border:1px solid var(--c-border);border-radius:12px;padding:16px;width:340px;max-width:90vw;max-height:80vh;overflow-y:auto;display:flex;flex-direction:column;gap:10px',
}, [
el('h3', { style: 'margin:0;font-size:0.95rem;color:var(--ink)' },
[isEdit ? t('sentinel.editRule') : t('sentinel.addRule')]),
labelInput(t('sentinel.ruleName'), 'rule-name', existing?.name || ''),
labelSelect(t('sentinel.triggerType'), 'rule-trigger', TRIGGER_TYPES, existing?.trigger_type || 'new_device'),
labelSelect(t('sentinel.ruleLogic'), 'rule-logic', ['AND', 'OR'], existing?.logic || 'AND'),
labelInput(t('sentinel.cooldown') + ' (s)', 'rule-cooldown', String(existing?.cooldown_s ?? 60), 'number'),
el('div', { style: 'font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px' },
[t('sentinel.conditions')]),
...CONDITION_KEYS.map(key =>
labelInput(key, `rule-cond-${key}`, conditions[key] ?? '', 'text', key === 'trusted_only' ? 'checkbox' : undefined)
),
el('div', { style: 'font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px' },
[t('sentinel.ruleActions')]),
...ACTION_TYPES.map(act =>
el('label', { style: 'display:flex;align-items:center;gap:6px;font-size:0.75rem;color:var(--ink);cursor:pointer' }, [
el('input', { type: 'checkbox', 'data-action': act, ...(actions.includes(act) ? { checked: '' } : {}) }),
act,
])
),
el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;margin-top:6px' }, [
el('button', {
class: 'sentinel-toggle', id: 'rule-cancel',
style: 'padding:5px 12px',
}, [t('sentinel.cancel')]),
el('button', {
class: 'sentinel-toggle active', id: 'rule-save',
style: 'padding:5px 12px',
}, [t('sentinel.save')]),
]),
]);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
// Close on backdrop click
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
// Cancel
$('#rule-cancel', modal).addEventListener('click', () => backdrop.remove());
// Save
$('#rule-save', modal).addEventListener('click', async () => {
const name = $('[data-field="rule-name"]', modal)?.value?.trim();
const triggerType = $('[data-field="rule-trigger"]', modal)?.value;
const logic = $('[data-field="rule-logic"]', modal)?.value;
const cooldown = parseInt($('[data-field="rule-cooldown"]', modal)?.value || '60');
if (!name) { toast(t('sentinel.nameRequired'), 2500, 'error'); return; }
// Gather conditions
const conds = {};
for (const key of CONDITION_KEYS) {
const input = $(`[data-field="rule-cond-${key}"]`, modal);
if (!input) continue;
const val = input.type === 'checkbox' ? (input.checked ? '1' : '') : input.value.trim();
if (val) conds[key] = val;
}
// Gather actions
const selectedActions = [];
$$('[data-action]', modal).forEach(cb => {
if (cb.checked) selectedActions.push(cb.dataset.action);
});
if (selectedActions.length === 0) selectedActions.push('notify_web');
const payload = {
rule: {
...(isEdit ? { id: existing.id } : {}),
name,
trigger_type: triggerType,
logic,
cooldown_s: cooldown,
conditions: conds,
actions: selectedActions,
enabled: isEdit ? existing.enabled : 1,
},
};
try {
await api.post('/api/sentinel/rule', payload);
toast(isEdit ? t('sentinel.ruleUpdated') : t('sentinel.ruleCreated'), 2000, 'success');
backdrop.remove();
await refreshRules();
} catch (err) { toast(err.message, 3000, 'error'); }
});
}
function labelInput(label, field, value, type = 'text', inputType) {
const actualType = inputType || type;
if (actualType === 'checkbox') {
return el('label', {
style: 'display:flex;align-items:center;gap:6px;font-size:0.75rem;color:var(--ink);cursor:pointer',
}, [
el('input', { type: 'checkbox', 'data-field': field, ...(value === '1' ? { checked: '' } : {}) }),
label,
]);
}
return el('div', { style: 'display:flex;flex-direction:column;gap:2px' }, [
el('label', { style: 'font-size:0.68rem;color:var(--muted);font-weight:600' }, [label]),
el('input', {
type: actualType,
'data-field': field,
value: value,
class: 'sentinel-notifier-input',
}),
]);
}
function labelSelect(label, field, options, selected) {
return el('div', { style: 'display:flex;flex-direction:column;gap:2px' }, [
el('label', { style: 'font-size:0.68rem;color:var(--muted);font-weight:600' }, [label]),
el('select', {
'data-field': field,
class: 'sentinel-notifier-input',
}, options.map(o =>
el('option', { value: o, ...(o === selected ? { selected: '' } : {}) }, [o])
)),
]);
}
/* ── Devices ───────────────────────────────────────────── */
function paintDevices(container) {
if (devices.length === 0) {
container.appendChild(
el('div', { style: 'color:var(--muted);text-align:center;padding:20px;font-size:0.75rem' },
[t('sentinel.noDevices')])
);
return;
}
for (const dev of devices) {
const mac = dev.mac_address;
container.appendChild(
el('div', { class: 'sentinel-notifier-row' }, [
el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, [
el('span', {
class: 'sentinel-rule-name',
style: 'font-family:monospace;font-size:0.75rem',
}, [mac]),
el('span', {
style: `font-size:0.6rem;padding:1px 6px;border-radius:4px;font-weight:700;${dev.trusted ? 'background:rgba(0,255,154,0.15);color:var(--acid)' : 'background:rgba(255,255,255,0.06);color:var(--muted)'}`,
}, [dev.trusted ? t('sentinel.trusted') : t('sentinel.untrusted')]),
]),
el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;align-items:center' }, [
miniInput(t('sentinel.alias'), `dev-alias-${mac}`, dev.alias || '', '80px'),
miniInput(t('sentinel.expectedIps'), `dev-ips-${mac}`, dev.expected_ips || '', '100px'),
el('label', { style: 'display:flex;align-items:center;gap:4px;font-size:0.65rem;color:var(--muted);cursor:pointer' }, [
el('input', { type: 'checkbox', 'data-field': `dev-trusted-${mac}`, ...(dev.trusted ? { checked: '' } : {}) }),
t('sentinel.trusted'),
]),
el('button', {
class: 'sentinel-toggle',
'data-dev-save': mac,
style: 'padding:2px 6px;font-size:0.6rem',
}, ['💾']),
]),
el('div', { class: 'sentinel-rule-type' }, [
`${t('sentinel.lastSeen')}: ${formatTime(dev.last_seen)}`,
dev.notes ? ` · ${dev.notes}` : '',
]),
])
);
}
}
function miniInput(placeholder, field, value, width) {
return el('input', {
type: 'text',
placeholder,
'data-field': field,
value,
class: 'sentinel-notifier-input',
style: `width:${width};padding:3px 5px;font-size:0.68rem`,
});
}
async function saveDevice(mac) {
const alias = $(`[data-field="dev-alias-${mac}"]`, root)?.value || '';
const ips = $(`[data-field="dev-ips-${mac}"]`, root)?.value || '';
const trusted = $(`[data-field="dev-trusted-${mac}"]`, root)?.checked ? 1 : 0;
try {
await api.post('/api/sentinel/device', { mac_address: mac, alias, expected_ips: ips, trusted });
toast(t('sentinel.deviceSaved'), 2000, 'success');
} catch (err) { toast(err.message, 3000, 'error'); }
}
/* ── Notifiers ─────────────────────────────────────────── */
function paintNotifiers(container) {
const fields = [
{ key: 'discord_webhook', label: t('sentinel.discordWebhook'), placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'webhook_url', label: t('sentinel.webhookUrl'), placeholder: 'https://example.com/hook' },
{ key: 'email_smtp_host', label: t('sentinel.smtpHost'), placeholder: 'smtp.gmail.com' },
{ key: 'email_smtp_port', label: t('sentinel.smtpPort'), placeholder: '587' },
{ key: 'email_username', label: t('sentinel.smtpUser'), placeholder: 'user@example.com' },
{ key: 'email_password', label: t('sentinel.smtpPass'), placeholder: '••••••••', type: 'password' },
{ key: 'email_from', label: t('sentinel.emailFrom'), placeholder: 'sentinel@bjorn.local' },
{ key: 'email_to', label: t('sentinel.emailTo'), placeholder: 'admin@example.com' },
];
for (const f of fields) {
container.appendChild(
el('div', { class: 'sentinel-notifier-row' }, [
el('label', { class: 'sentinel-notifier-label' }, [f.label]),
el('input', {
type: f.type || 'text',
'data-notifier': f.key,
placeholder: f.placeholder,
class: 'sentinel-notifier-input',
}),
])
);
}
container.appendChild(
el('button', {
class: 'sentinel-toggle active',
id: 'sentinel-save-notifiers',
style: 'align-self:flex-end;margin-top:6px;padding:5px 14px',
}, [t('sentinel.saveNotifiers')])
);
}
async function saveNotifiers() {
const notifiers = {};
$$('[data-notifier]', root).forEach(input => {
const val = input.value.trim();
if (val) notifiers[input.dataset.notifier] = val;
});
try {
await api.post('/api/sentinel/notifiers', { notifiers });
toast(t('sentinel.notifiersSaved'), 2000, 'success');
} catch (err) { toast(err.message, 3000, 'error'); }
}
/* ── Helpers ───────────────────────────────────────────── */
function formatEventType(type) {
return (type || 'unknown').replace(/_/g, ' ');
}
function formatTime(ts) {
if (!ts) return '—';
try {
const d = new Date(ts.includes('Z') || ts.includes('+') ? ts : ts + 'Z');
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60000) return t('sentinel.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} catch { return ts; }
}

View File

@@ -71,8 +71,11 @@ export async function mount(container) {
}
export function unmount() {
clearTimeout(searchDebounce);
searchDebounce = null;
if (searchDebounce != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounce);
else clearTimeout(searchDebounce);
searchDebounce = null;
}
if (poller) { poller.stop(); poller = null; }
if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch {} disposeSidebarLayout = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
@@ -96,13 +99,13 @@ function buildShell() {
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'),
statItem('\u{1F6E1}', 'total-cves', t('vulns.totalCVEs')),
statItem('\u{1F534}', 'active-vulns', t('vulns.active')),
statItem('\u2705', 'remediated-vulns', t('vulns.remediated')),
statItem('\u{1F525}', 'critical-count', t('vulns.critical')),
statItem('\u{1F5A5}', 'affected-hosts', t('vulns.hosts')),
statItem('\u{1F4A3}', 'exploit-count', t('vulns.withExploit')),
statItem('\u26A0', 'kev-count', t('vulns.kev')),
]),
/* freshness */
el('div', { id: 'vuln-freshness', style: 'font-size:.75rem;opacity:.5;padding:8px 0 0 4px' }),
@@ -113,29 +116,29 @@ function buildShell() {
class: 'vuln-btn exploit-btn',
style: 'width:100%;font-weight:600',
onclick: runFeedSync,
}, ['\u{1F504} Update Exploit Feeds']),
}, ['\u{1F504} ' + t('vulns.updateFeeds')]),
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('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, [t('vulns.sortBy')]),
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('option', { value: 'cvss_score' }, [t('vulns.cvssScore')]),
el('option', { value: 'severity' }, [t('vulns.severity')]),
el('option', { value: 'last_seen' }, [t('vulns.lastSeen')]),
el('option', { value: 'first_seen' }, [t('vulns.firstSeen')]),
]),
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']),
el('option', { value: 'desc' }, [t('common.descending')]),
el('option', { value: 'asc' }, [t('common.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('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, [t('vulns.dateFilter')]),
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']),
el('button', { class: 'vuln-btn', style: 'margin-top:6px;width:100%', onclick: clearDateFilter }, [t('vulns.clearDates')]),
]),
]),
]);
@@ -147,9 +150,9 @@ function buildShell() {
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 active', id: 'vuln-view-cve', onclick: () => switchView('cve') }, [t('vulns.cveView')]),
el('button', { class: 'vuln-btn', id: 'vuln-view-host', onclick: () => switchView('host') }, [t('vulns.hostView')]),
el('button', { class: 'vuln-btn', id: 'vuln-view-exploits', onclick: () => switchView('exploits') }, ['\u{1F4A3} ' + t('vulns.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']),
@@ -200,10 +203,11 @@ async function fetchVulnerabilities() {
if (historyMode) return;
try {
const data = await api.get('/list_vulnerabilities', { timeout: 10000 });
if (!tracker) return; /* unmounted while awaiting */
vulnerabilities = Array.isArray(data) ? data : (data?.vulnerabilities || []);
lastFetchTime = new Date();
const f = $('#vuln-freshness');
if (f) f.textContent = `Last refresh: ${lastFetchTime.toLocaleTimeString()}`;
if (f) f.textContent = t('vulns.lastRefresh', { time: lastFetchTime.toLocaleTimeString() });
updateStats();
filterAndRender();
} catch (err) {
@@ -220,8 +224,8 @@ 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';
if (btn) { btn.disabled = true; btn.textContent = '\u23F3 ' + t('vulns.downloading'); }
if (status) status.textContent = t('vulns.syncingFeeds');
try {
const res = await api.post('/api/feeds/sync', {}, { timeout: 120000 });
@@ -236,7 +240,7 @@ async function runFeedSync() {
} catch (err) {
if (status) status.textContent = `\u274C ${err.message}`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '\u{1F504} Update Exploit Feeds'; }
if (btn) { btn.disabled = false; btn.textContent = '\u{1F504} ' + t('vulns.updateFeeds'); }
}
}
@@ -246,12 +250,12 @@ async function loadFeedStatus() {
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; }
if (!entries.length) { status.textContent = t('vulns.noSyncYet'); 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`;
status.textContent = t('vulns.lastSync', { date: `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`, count: res.total_exploits || 0 });
}
} catch { /* ignore */ }
}
@@ -364,7 +368,7 @@ function renderCVEView() {
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; }
if (!page.length) { grid.appendChild(emptyState(t('vulns.noVulns'))); return; }
page.forEach((v, i) => {
const exploitChips = buildExploitChips(v);
@@ -386,13 +390,13 @@ function renderCVEView() {
...(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']),
el('span', { style: 'font-size:.72rem;opacity:.35;white-space:nowrap' }, ['\u{1F4CB} ' + t('vulns.clickDetails')]),
]),
/* meta */
el('div', { class: 'vuln-meta' }, [
metaItem('IP', v.ip),
metaItem('Host', v.hostname),
metaItem('Port', v.port),
metaItem(t('common.ip'), v.ip),
metaItem(t('common.host'), v.hostname),
metaItem(t('common.port'), v.port),
]),
/* description */
el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px;line-height:1.4' }, [
@@ -426,7 +430,7 @@ function renderHostView() {
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; }
if (!page.length) { grid.appendChild(emptyState(t('vulns.noHostsFound'))); return; }
page.forEach((host, i) => {
const hostId = `host-${i + (currentPage - 1) * ITEMS_PER_PAGE}`;
@@ -442,8 +446,8 @@ function renderHostView() {
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`])] : []),
el('span', { class: 'stat-label' }, [t('vulns.vulnsCount', { count: host.vulns.length })]),
...(remediated > 0 ? [el('span', { class: 'vuln-tag remediated' }, [`${remediated} ${t('vulns.fixed')}`])] : []),
...(host.vulns.some(v => v.has_exploit) ? [el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}'])] : []),
]),
el('div', { class: 'host-severity-pills' }, [
@@ -456,10 +460,10 @@ function renderHostView() {
]),
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)),
metaItem(t('common.ip'), host.ip),
metaItem(t('common.mac'), host.mac),
metaItem(t('vulns.active'), host.vulns.filter(v => v.is_active === 1).length),
metaItem(t('vulns.maxCvss'), Math.max(...host.vulns.map(v => parseFloat(v.cvss_score) || 0)).toFixed(1)),
]),
...sortVulnsByPriority(host.vulns).map(v => {
const exploitChips = buildExploitChips(v);
@@ -478,8 +482,8 @@ function renderHostView() {
...(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)),
metaItem(t('common.port'), v.port),
metaItem(t('common.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' : ''),
@@ -509,10 +513,10 @@ function renderExploitsView() {
if (!page.length) {
const wrapper = el('div', { style: 'text-align:center;padding:40px' }, [
emptyState('\u{1F4A3} No exploit data yet'),
emptyState('\u{1F4A3} ' + t('vulns.noExploitData')),
el('div', { style: 'margin-top:16px' }, [
el('button', { class: 'vuln-btn exploit-btn', onclick: runGlobalExploitSearch },
['\u{1F4A3} Search All Exploits now']),
['\u{1F4A3} ' + t('vulns.searchExploits')]),
]),
]);
grid.appendChild(wrapper);
@@ -538,7 +542,7 @@ function renderExploitsView() {
...(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('span', { style: 'font-size:.72rem;opacity:.35' }, ['\u{1F4CB} ' + t('vulns.clickDetails')]),
]),
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' }, [
@@ -587,13 +591,13 @@ function renderHistory() {
grid.appendChild(el('div', { style: 'margin-bottom:12px' }, [
el('input', {
type: 'text', class: 'global-search-input', value: historySearch,
placeholder: 'Filter history\u2026',
placeholder: t('vulns.filterHistory'),
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; }
if (!filtered.length) { grid.appendChild(emptyState(t('vulns.noHistory'))); 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` }, [
@@ -602,20 +606,20 @@ function renderHistory() {
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),
metaItem(t('common.date'), entry.seen_at ? new Date(entry.seen_at).toLocaleString() : t('vulns.na')),
metaItem(t('common.ip'), entry.ip), metaItem(t('common.host'), entry.hostname),
metaItem(t('common.port'), entry.port), metaItem(t('common.mac'), entry.mac_address),
]),
]));
});
if (pagDiv && hTotal > 1) {
pagDiv.appendChild(pageBtn('Prev', historyPage > 1, () => { historyPage--; renderHistory(); }));
pagDiv.appendChild(pageBtn(t('webenum.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`]));
pagDiv.appendChild(pageBtn(t('webenum.next'), historyPage < hTotal, () => { historyPage++; renderHistory(); }));
pagDiv.appendChild(el('span', { class: 'vuln-page-info' }, [t('vulns.pageInfo', { page: historyPage, total: hTotal, count: filtered.length })]));
}
}
@@ -647,7 +651,7 @@ async function showCVEDetails(cveId) {
].forEach(([label, href, cls]) => chipsEl.appendChild(refChip(label, href, cls)));
}
if (body) { empty(body); body.appendChild(el('div', { class: 'page-loading' }, ['Loading\u2026'])); }
if (body) { empty(body); body.appendChild(el('div', { class: 'page-loading' }, [t('common.loading')])); }
modal.classList.add('show');
try {
@@ -655,26 +659,26 @@ async function showCVEDetails(cveId) {
if (!body) return;
empty(body);
if (data.description) body.appendChild(modalSection('Description', data.description));
if (data.description) body.appendChild(modalSection(t('vulns.description'), data.description));
if (data.cvss) {
const s = data.cvss;
body.appendChild(modalSection('CVSS',
`Score: ${s.baseScore || 'N/A'} | Severity: ${s.baseSeverity || 'N/A'}` +
`${t('vulns.score')}: ${s.baseScore || t('vulns.na')} | ${t('vulns.severity')}: ${s.baseSeverity || t('vulns.na')}` +
(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)}%`
if (data.is_kev) body.appendChild(modalSection('\u26A0 ' + t('vulns.cisaKev'), t('vulns.cisaKevMsg')));
if (data.epss) body.appendChild(modalSection(t('vulns.epss'),
`${t('vulns.probability')}: ${(data.epss.probability * 100).toFixed(2)}% | ${t('vulns.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: 'modal-section-title' }, [t('vulns.affectedProducts')]),
el('div', { class: 'vuln-affected-table' }, [
el('div', { class: 'vuln-affected-row header' }, [el('span', {}, ['Vendor']), el('span', {}, ['Product']), el('span', {}, ['Versions'])]),
el('div', { class: 'vuln-affected-row header' }, [el('span', {}, [t('common.vendor')]), el('span', {}, [t('vulns.product')]), el('span', {}, [t('vulns.versions')])]),
...rows.map(r => el('div', { class: 'vuln-affected-row' }, [el('span', {}, [r.vendor]), el('span', {}, [r.product]), el('span', {}, [r.versions])])),
]),
]));
@@ -683,7 +687,7 @@ async function showCVEDetails(cveId) {
/* Exploits section */
const exploits = data.exploits || [];
const exploitSection = el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['\u{1F4A3} Exploits & References']),
el('div', { class: 'modal-section-title' }, ['\u{1F4A3} ' + t('vulns.exploitsRefs')]),
/* dynamic entries from DB */
...exploits.map(entry => {
const isStr = typeof entry === 'string';
@@ -704,7 +708,7 @@ async function showCVEDetails(cveId) {
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.'])
? el('div', { style: 'opacity:.45;font-size:.8rem;margin-top:6px' }, [t('vulns.noExploitRecords')])
: null,
].filter(Boolean));
body.appendChild(exploitSection);
@@ -712,16 +716,16 @@ async function showCVEDetails(cveId) {
/* References */
if (data.references && data.references.length > 0) {
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['References']),
el('div', { class: 'modal-section-title' }, [t('vulns.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.lastModified) body.appendChild(modalSection(t('vulns.lastModified'), 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.']));
body.appendChild(el('div', { style: 'opacity:.6;padding:20px;text-align:center' }, [t('vulns.noEnrichment')]));
}
} catch (err) {
if (body) { empty(body); body.appendChild(el('div', { style: 'color:var(--danger);padding:20px' }, [`Failed: ${err.message}`])); }
@@ -754,15 +758,27 @@ function normalizeAffected(affected) {
});
}
/* ════════════════════════════════════════
GLOBAL EXPLOIT SEARCH (alias for feed sync from exploits view)
═══════════════════════════════════════ */
async function runGlobalExploitSearch() {
await runFeedSync();
}
/* ════════════════════════════════════════
SEARCH / FILTER / SORT HANDLERS
═══════════════════════════════════════ */
function onSearch(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
if (searchDebounce != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounce);
else clearTimeout(searchDebounce);
}
const handler = () => {
searchTerm = e.target.value; currentPage = 1; filterAndRender();
const b = e.target.nextElementSibling; if (b) b.classList.toggle('show', searchTerm.length > 0);
}, 300);
searchDebounce = null;
};
searchDebounce = tracker ? tracker.trackTimeout(handler, 300) : setTimeout(handler, 300);
}
function clearSearch() {
const inp = $('#vuln-search'); if (inp) inp.value = '';
@@ -813,11 +829,11 @@ 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)));
pag.appendChild(pageBtn(t('webenum.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`]));
pag.appendChild(pageBtn(t('webenum.next'), currentPage < totalPages, () => changePage(currentPage + 1)));
pag.appendChild(el('span', { class: 'vuln-page-info' }, [t('vulns.resultsInfo', { page: currentPage, total: totalPages, count: filteredVulns.length })]));
}
function pageBtn(label, enabled, onclick, active = false) {
return el('button', {
@@ -880,7 +896,7 @@ function onModalBackdrop(e) { if (e.target.classList.contains('vuln-modal')) clo
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')]),
el('span', { class: 'meta-value' }, [String(value ?? t('vulns.na'))]),
]);
}
function modalSection(title, text) {
@@ -899,7 +915,7 @@ function sevPill(sev, count) {
return el('span', { class: `severity-badge severity-${sev}` }, [`${count} ${sev}`]);
}
function formatDate(d) {
if (!d) return 'Unknown';
if (!d) return t('vulns.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); }
}

View File

@@ -43,8 +43,11 @@ export async function mount(container) {
}
export function unmount() {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
searchDebounceId = null;
if (searchDebounceId != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounceId);
else clearTimeout(searchDebounceId);
searchDebounceId = null;
}
if (tracker) { tracker.cleanupAll(); tracker = null; }
allData = [];
filteredData = [];
@@ -69,10 +72,10 @@ 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)'),
statItem('we-stat-total', t('webenum.totalResults')),
statItem('we-stat-hosts', t('webenum.uniqueHosts')),
statItem('we-stat-success', t('webenum.successCount')),
statItem('we-stat-errors', t('webenum.errorCount')),
]),
/* controls row */
el('div', { class: 'webenum-controls' }, [
@@ -80,19 +83,19 @@ function buildShell() {
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',
placeholder: t('webenum.searchPlaceholder'),
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']),
el('button', { class: 'vuln-btn', onclick: () => fetchAllData() }, [t('common.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),
buildSelect('we-filter-host', t('webenum.allHosts'), onHostFilter),
buildSelect('we-filter-status', t('webenum.allStatus'), onStatusFamilyFilter),
buildSelect('we-filter-port', t('webenum.allPorts'), onPortFilter),
el('input', {
type: 'date', class: 'webenum-date-input', id: 'we-filter-date',
onchange: onDateFilter,
@@ -100,8 +103,8 @@ function buildShell() {
]),
/* 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']),
el('button', { class: 'vuln-btn', onclick: () => exportData('json') }, [t('webenum.exportJson')]),
el('button', { class: 'vuln-btn', onclick: () => exportData('csv') }, [t('webenum.exportCsv')]),
]),
]),
/* status legend chips */
@@ -143,7 +146,7 @@ async function fetchAllData() {
const loading = $('#we-table-wrap');
if (loading) {
empty(loading);
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026']));
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading')]));
}
const ac = tracker ? tracker.trackAbortController() : null;
@@ -176,12 +179,13 @@ async function fetchAllData() {
if (page > MAX_PAGES_FETCH) fetchedLimit = true;
} catch (err) {
if (err.name === 'ApiError' && err.message === 'Aborted') return;
if (err.name === 'AbortError' || (err.name === 'ApiError' && err.message === 'Aborted')) return;
console.warn(`[${PAGE}] fetch error:`, err.message);
} finally {
if (ac && tracker) tracker.removeAbortController(ac);
}
if (!tracker) return; /* unmounted while fetching */
allData = accumulated.map(normalizeRow);
populateFilterDropdowns();
applyFilters();
@@ -210,14 +214,14 @@ function normalizeRow(row) {
Filter dropdowns — populate from unique values
══════════════════════════════════════════════════════════════ */
function populateFilterDropdowns() {
populateSelect('we-filter-host', 'All Hosts',
populateSelect('we-filter-host', t('webenum.allHosts'),
[...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);
populateSelect('we-filter-status', t('webenum.allStatus'), 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));
populateSelect('we-filter-port', t('webenum.allPorts'), ports.map(String));
}
function populateSelect(id, defaultLabel, options) {
@@ -359,7 +363,7 @@ function renderTable() {
empty(wrap);
if (filteredData.length === 0) {
wrap.appendChild(emptyState('No web enumeration results found'));
wrap.appendChild(emptyState(t('webenum.noResults')));
return;
}
@@ -368,14 +372,14 @@ function renderTable() {
/* 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' },
{ key: 'host', label: t('webenum.host') },
{ key: 'ip', label: t('webenum.ip') },
{ key: 'port', label: t('webenum.port') },
{ key: 'directory', label: t('webenum.directory') },
{ key: 'status', label: t('webenum.status') },
{ key: 'size', label: t('webenum.size') },
{ key: 'scan_date', label: t('webenum.scanDate') },
{ key: '_actions', label: t('webenum.actions') },
];
/* thead */
@@ -419,7 +423,7 @@ function renderTable() {
href: url, target: '_blank', rel: 'noopener noreferrer',
class: 'webenum-link', title: url,
onclick: (e) => e.stopPropagation(),
}, ['Open'])
}, [t('webenum.open')])
: el('span', { class: 'muted' }, ['-']),
]),
]);
@@ -454,32 +458,32 @@ function renderPagination() {
/* 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 label = n === 0 ? t('common.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:']),
el('span', { class: 'stat-label' }, [t('webenum.perPage')]),
perPageSel,
]));
if (total <= 1 && itemsPerPage !== 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
t('webenum.resultCount', { count: filteredData.length }),
]));
return;
}
if (itemsPerPage === 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
t('webenum.showingAll', { count: filteredData.length }),
]));
return;
}
/* Prev */
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
pag.appendChild(pageBtn(t('webenum.prev'), currentPage > 1, () => changePage(currentPage - 1)));
/* numbered buttons */
const start = Math.max(1, currentPage - 2);
@@ -489,11 +493,11 @@ function renderPagination() {
}
/* Next */
pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1)));
pag.appendChild(pageBtn(t('webenum.next'), currentPage < total, () => changePage(currentPage + 1)));
/* info */
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Page ${currentPage} of ${total} (${filteredData.length} results)`,
t('webenum.pageInfo', { current: currentPage, total, count: filteredData.length }),
]));
}
@@ -540,7 +544,10 @@ function onSortColumn(key) {
Filter handlers
══════════════════════════════════════════════════════════════ */
function onSearchInput(e) {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
if (searchDebounceId != null) {
if (tracker) tracker.clearTrackedTimeout(searchDebounceId);
else clearTimeout(searchDebounceId);
}
const val = e.target.value;
searchDebounceId = tracker
? tracker.trackTimeout(() => {
@@ -631,15 +638,15 @@ function showDetailModal(row) {
if (url) {
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
window.open(url, '_blank', 'noopener,noreferrer');
}}, ['Open URL']));
}}, [t('webenum.openUrl')]));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
copyText(url);
}}, ['Copy URL']));
}}, [t('webenum.copyUrl')]));
}
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']));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'json') }, [t('webenum.exportJson')]));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'csv') }, [t('webenum.exportCsv')]));
body.appendChild(actions);
modal.classList.add('show');
@@ -690,7 +697,9 @@ function buildCSV(data) {
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 : '');
let s = String(v != null ? v : '');
/* protect against CSV formula injection */
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"` : s;
});

View File

@@ -81,7 +81,7 @@ export async function mount(container) {
mainSelector: '.zl-main',
storageKey: 'sidebar:zombieland',
mobileBreakpoint: 900,
toggleLabel: t('common.menu') || 'Menu',
toggleLabel: t('common.menu'),
});
await refreshState();
syncSearchClearButton();
@@ -113,16 +113,16 @@ 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: 'sidetitle' }, [t('nav.zombieland')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide') || 'Hide']),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.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-total', t('zombie.total')),
statItem('zl-stat-alive', t('zombie.online')),
statItem('zl-stat-avg-cpu', t('zombie.avgCpu')),
statItem('zl-stat-avg-ram', t('zombie.avgRam')),
statItem('zl-stat-c2', L('zombieland.c2Status', 'C2 Port')),
]),
el('div', { class: 'zl-toolbar' }, [
@@ -222,9 +222,9 @@ function buildGenerateClientModal() {
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('label', {}, [t('zombie.sshHost')]), el('input', { id: 'sshHost', type: 'text', class: 'input' }),
el('label', {}, [t('zombie.sshUser')]), el('input', { id: 'sshUser', type: 'text', class: 'input' }),
el('label', {}, [t('zombie.sshPass')]), el('input', { id: 'sshPass', type: 'password', class: 'input' }),
]),
]),
el('div', { class: 'modal-actions' }, [
@@ -263,6 +263,7 @@ async function refreshState() {
api.get('/c2/status').catch(() => null),
api.get('/c2/agents').catch(() => null),
]);
if (!tracker) return; /* unmounted while awaiting */
if (status) { c2Running = !!status.running; c2Port = status.port || null; }
if (Array.isArray(agentList)) {
for (const a of agentList) {
@@ -281,8 +282,8 @@ async function refreshState() {
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.onopen = () => { sseHealthy = true; systemLog('info', t('zombie.connectedToC2')); };
eventSource.onerror = () => { sseHealthy = false; systemLog('error', t('zombie.c2ConnectionLost')); };
eventSource.addEventListener('status', (e) => {
try { const data = JSON.parse(e.data); c2Running = !!data.running; c2Port = data.port || null; updateStats(); } catch { }
});
@@ -295,7 +296,7 @@ function connectSSE() {
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.`);
systemLog('success', t('zombie.telemetryReceived', { name: agent.hostname || id }));
}
const card = $('[data-agent-id="' + id + '"]');
if (card) { card.classList.add('pulse'); tracker.trackTimeout(() => card.classList.remove('pulse'), 600); }
@@ -385,15 +386,15 @@ function createAgentCard(agent, now) {
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-hostname' }, [agent.hostname || t('common.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.os'), agent.os || t('common.unknown')),
infoRow(t('common.ip'), agent.ip || 'N/A'),
infoRow('CPU/RAM', `${agent.cpu || 0}% / ${agent.mem || 0}%`),
infoRow(t('zombie.cpuRam'), `${agent.cpu || 0}% / ${agent.mem || 0}%`),
]),
el('div', { class: 'zl-ecg-row' }, [
createECG(id, pres.color, pres.bpm),
@@ -423,12 +424,13 @@ 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 sv = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; };
sv('zl-stat-total', String(all.length));
sv('zl-stat-alive', 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}%`;
sv('zl-stat-avg-cpu', `${avgCPU}%`);
sv('zl-stat-avg-ram', `${avgRAM}%`);
const c2El = $('#zl-stat-c2');
if (c2El) {
c2El.textContent = c2Running ? `${t('status.online')} :${c2Port || '?'}` : t('status.offline');
@@ -499,7 +501,7 @@ function onRefresh() {
}
async function onStartC2() {
const port = prompt(L('zombie.enterC2Port', 'Enter C2 port'), '5555');
const port = prompt(t('zombie.enterC2Port'), '5555');
if (!port) return;
try {
await api.post('/c2/start', { port: parseInt(port) });
@@ -520,8 +522,8 @@ async function onStopC2() {
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.`);
toast(t('zombie.staleFound', { count: result.count }));
systemLog('info', t('zombie.staleCheck', { count: result.count }));
} catch (err) { toast('Failed to fetch stale agents', 'error'); }
}
@@ -548,7 +550,7 @@ async function onConfirmGenerate() {
};
try {
const result = await api.post('/c2/generate_client', data);
toast(`Client ${clientId} generated`, 'success');
toast(t('zombie.clientGenerated', { id: clientId }), 'success');
if ($('#deploySSH').checked) {
await api.post('/c2/deploy', {
client_id: clientId,
@@ -558,7 +560,7 @@ async function onConfirmGenerate() {
lab_user: data.lab_user,
lab_password: data.lab_password,
});
toast(`Deployment to ${$('#sshHost').value} started`);
toast(t('zombie.deployStarted', { host: $('#sshHost').value }));
}
$('#generateModal').style.display = 'none';
if (result.filename) {
@@ -597,8 +599,8 @@ function systemLog(level, message) {
output.scrollTop = output.scrollHeight;
}
function clearConsole() { empty($('#zl-console-output')); }
function clearLogs() { empty($('#zl-logs-output')); }
function clearConsole() { const e = $('#zl-console-output'); if (e) empty(e); }
function clearLogs() { const e = $('#zl-logs-output'); if (e) empty(e); }
function onCmdKeyDown(e) {
if (e.key === 'Enter') onSendCommand();
@@ -623,7 +625,7 @@ async function onSendCommand() {
else { targets = [target]; }
if (target !== 'broadcast' && targets.length === 0) {
toast('No agents selected for command.', 'warning');
toast(t('zombie.noAgentsSelected'), 'warning');
return;
}
@@ -672,16 +674,16 @@ async function browseDirectory() {
const path = $('#browserPath').value || '/';
const fileList = $('#fileList');
empty(fileList);
fileList.textContent = 'Loading...';
fileList.textContent = t('common.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.');
toast(t('zombie.browseCommandSent'));
} catch (err) {
toast('Failed to send browse command', 'error');
fileList.textContent = 'Error.';
toast(t('zombie.browseCommandFailed'), 'error');
fileList.textContent = t('common.error');
}
}
@@ -699,8 +701,8 @@ function onUploadFile() {
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'); }
toast(t('zombie.uploadStarted', { name: file.name }));
} catch { toast(t('zombie.uploadFailed'), 'error'); }
};
reader.readAsBinaryString(file);
}