/** * 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'; }