Files
Bjorn/web/js/pages/network.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

567 lines
19 KiB
JavaScript

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