mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 17:01:58 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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">☰</button>
|
||||
<button class="btn icon" id="btnIns" title="Open inspector panel" aria-controls="right">⚙</button>
|
||||
<button class="btn" id="btnAutoLayout" title="Auto-layout">⚡ Auto-layout</button>
|
||||
<button class="btn" id="btnRepel" title="Repel overlap">Repel</button>
|
||||
<button class="btn primary" id="btnApply" title="Save and apply">Apply</button>
|
||||
<button class="btn" id="btnHelp" title="Show shortcuts and gestures">Help</button>
|
||||
<button class="btn icon" id="btnPal" title="${t('studio.openPalette')}" aria-controls="left">☰</button>
|
||||
<button class="btn icon" id="btnIns" title="${t('studio.openInspector')}" aria-controls="right">⚙</button>
|
||||
<button class="btn" id="btnAutoLayout" title="${t('studio.autoLayout')}">⚡ ${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">⋮</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">×</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')}">×</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">×</button>
|
||||
<input class="search" id="filterActions" placeholder="${t('studio.filterActions')}">
|
||||
<button class="search-clear" id="clearFilterActions" aria-label="${t('common.clear')}">×</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">×</button>
|
||||
<input class="search" id="filterHosts" placeholder="${t('studio.filterHosts')}">
|
||||
<button class="search-clear" id="clearFilterHosts" aria-label="${t('common.clear')}">×</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">×</button>
|
||||
<strong>${t('studio.tips')}</strong>
|
||||
<span>${t('studio.tipsText')}</span>
|
||||
<button id="btnHideCanvasHint" class="btn icon" aria-label="${t('common.hide')}">×</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">×</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')}">×</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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
528
web/js/pages/bifrost.js
Normal 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; }
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
536
web/js/pages/loki.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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}`])]),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
752
web/js/pages/sentinel.js
Normal 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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user