Files
Bjorn/web/js/pages/netkb.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

449 lines
16 KiB
JavaScript

/**
* NetKB (Network Knowledge Base) page module.
* Displays discovered hosts with ports, actions, search, sort, filter, and 3 view modes.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'netkb';
const L = (key, fallback, vars = {}) => {
const v = t(key, vars);
return v === key ? fallback : v;
};
/* ── state ── */
let tracker = null;
let poller = null;
let originalData = [];
let viewMode = 'grid';
let showNotAlive = false;
let currentSort = 'ip';
let sortOrder = 1;
let currentFilter = null;
let searchTerm = '';
let searchDebounce = null;
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
const setPref = (k, v) => { try { localStorage.setItem(k, v); } catch { /* noop */ } };
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const savedView = getPref('netkb:view', isMobile() ? 'list' : 'grid');
const savedOffline = getPref('netkb:offline', 'false') === 'true';
const savedSearch = getPref('netkb:search', '');
viewMode = isMobile() && savedView === 'grid' ? 'list' : savedView;
showNotAlive = savedOffline;
if (savedSearch) searchTerm = savedSearch.toLowerCase();
container.appendChild(buildShell(savedSearch));
syncViewUI();
syncOfflineUI();
syncClearBtn();
tracker.trackEventListener(window, 'resize', () => {
if (isMobile() && viewMode === 'grid') { viewMode = 'list'; syncViewUI(); refreshDisplay(); }
});
/* close search popover on outside click */
tracker.trackEventListener(document, 'click', (e) => {
const pop = $('#netkb-searchPop');
const btn = $('#netkb-btnSearch');
if (pop && btn && !pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('show');
});
tracker.trackEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') { const pop = $('#netkb-searchPop'); if (pop) pop.classList.remove('show'); }
});
await refresh();
poller = new Poller(refresh, 5000);
poller.start();
}
export function unmount() {
clearTimeout(searchDebounce);
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
originalData = [];
searchTerm = '';
currentFilter = null;
}
/* ── data fetch ── */
async function refresh() {
try {
const data = await api.get('/netkb_data', { timeout: 8000 });
originalData = Array.isArray(data) ? data : [];
refreshDisplay();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
/* ── shell ── */
function buildShell(savedSearch) {
return el('div', { class: 'netkb-container' }, [
el('div', { class: 'netkb-toolbar-wrap' }, [
el('div', { class: 'netkb-toolbar', id: 'netkb-toolbar' }, [
el('button', {
class: 'icon-btn', id: 'netkb-btnSearch', title: t('common.search'),
onclick: toggleSearchPop
}, ['\u{1F50D}']),
el('div', { class: 'search-pop', id: 'netkb-searchPop' }, [
el('div', { class: 'search-input-wrap' }, [
el('input', {
type: 'text', id: 'netkb-searchInput',
placeholder: t('netkb.searchPlaceholder'),
title: t('netkb.searchHint'),
value: savedSearch || '', oninput: onSearchInput
}),
el('button', {
class: 'search-clear', id: 'netkb-searchClear', type: 'button',
'aria-label': 'Clear', onclick: clearSearch
}, ['\u2715']),
]),
el('div', { class: 'search-hint' }, [t('netkb.searchHint')]),
]),
el('div', { class: 'segmented', id: 'netkb-viewSeg' }, [
el('button', { 'data-view': 'grid', onclick: () => setView('grid') }, [L('common.grid', 'Grid')]),
el('button', { 'data-view': 'list', onclick: () => setView('list') }, [L('common.list', 'List')]),
el('button', { 'data-view': 'table', onclick: () => setView('table') }, [L('common.table', 'Table')]),
]),
el('label', { class: 'kb-switch', id: 'netkb-offlineSwitch', 'data-on': String(showNotAlive) }, [
el('input', {
type: 'checkbox', id: 'netkb-toggleOffline',
...(showNotAlive ? { checked: '' } : {}),
onchange: (e) => setOffline(e.target.checked)
}),
el('span', {}, [L('netkb.showOffline', 'Show offline')]),
el('span', { class: 'track' }, [el('span', { class: 'thumb' })]),
]),
]),
]),
el('div', { class: 'netkb-content' }, [
el('div', { id: 'netkb-card-container', class: 'card-container' }),
el('div', { id: 'netkb-table-container', class: 'table-wrap hidden' }),
]),
]);
}
/* ── search ── */
function toggleSearchPop() {
const pop = $('#netkb-searchPop');
if (!pop) return;
pop.classList.toggle('show');
if (pop.classList.contains('show')) {
const inp = $('#netkb-searchInput');
if (inp) { inp.focus(); inp.select(); }
}
}
function onSearchInput(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value.trim().toLowerCase();
setPref('netkb:search', e.target.value.trim());
refreshDisplay();
syncClearBtn();
}, 120);
}
function clearSearch() {
const inp = $('#netkb-searchInput');
if (inp) { inp.value = ''; inp.focus(); }
searchTerm = '';
setPref('netkb:search', '');
refreshDisplay();
syncClearBtn();
}
function syncClearBtn() {
const btn = $('#netkb-searchClear');
if (btn) btn.style.display = searchTerm ? '' : 'none';
}
/* ── view mode ── */
function setView(mode) {
if (isMobile() && mode === 'grid') mode = 'list';
viewMode = mode;
setPref('netkb:view', mode);
syncViewUI();
refreshDisplay();
}
function syncViewUI() {
const cards = $('#netkb-card-container');
const table = $('#netkb-table-container');
if (!cards || !table) return;
if (viewMode === 'table') {
cards.classList.add('hidden');
table.classList.remove('hidden');
} else {
table.classList.add('hidden');
cards.classList.remove('hidden');
}
$$('#netkb-viewSeg button').forEach(b => {
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
});
}
/* ── offline toggle ── */
function setOffline(on) {
showNotAlive = !!on;
syncOfflineUI();
setPref('netkb:offline', String(on));
refreshDisplay();
}
function syncOfflineUI() {
const sw = $('#netkb-offlineSwitch');
if (sw) sw.dataset.on = String(showNotAlive);
const cb = $('#netkb-toggleOffline');
if (cb) cb.checked = showNotAlive;
}
/* ── sort / filter ── */
function sortBy(key) {
if (currentSort === key) sortOrder = -sortOrder;
else { currentSort = key; sortOrder = 1; }
refreshDisplay();
}
function filterBy(criteria, ev) {
if (ev) ev.stopPropagation();
currentFilter = (currentFilter === criteria) ? null : criteria;
refreshDisplay();
}
/* ── paint orchestrator ── */
function refreshDisplay() {
let data = [...originalData];
if (searchTerm) data = data.filter(matchesSearch);
if (currentFilter) {
data = data.filter(item => {
switch (currentFilter) {
case 'hasActions': return item.actions && item.actions.some(a => a && a.status);
case 'hasPorts': return item.ports && item.ports.some(Boolean);
case 'toggleAlive': return !item.alive;
default: return true;
}
});
}
if (currentSort) {
const ipToNum = ip => !ip ? 0 : ip.split('.').reduce((a, p) => (a << 8) + (+p || 0), 0);
data.sort((a, b) => {
if (currentSort === 'ports') {
return sortOrder * ((a.ports?.filter(Boolean).length || 0) - (b.ports?.filter(Boolean).length || 0));
}
if (currentSort === 'ip') return sortOrder * (ipToNum(a.ip) - ipToNum(b.ip));
const av = (a[currentSort] || '').toString();
const bv = (b[currentSort] || '').toString();
return sortOrder * av.localeCompare(bv, undefined, { numeric: true });
});
}
if (viewMode === 'table') renderTable(data);
else renderCards(data);
}
/* ── search ── */
const norm = v => (v ?? '').toString().toLowerCase();
function matchesSearch(item) {
if (!searchTerm) return true;
const q = searchTerm;
if (norm(item.hostname).includes(q)) return true;
if (norm(item.ip).includes(q)) return true;
if (norm(item.mac).includes(q)) return true;
if (norm(item.vendor).includes(q)) return true;
if (norm(item.essid).includes(q)) return true;
if (Array.isArray(item.ports) && item.ports.some(p => norm(p).includes(q))) return true;
if (Array.isArray(item.actions) && item.actions.some(a => norm(a?.name).includes(q))) return true;
return false;
}
/* ── card rendering ── */
function renderCards(data) {
const container = $('#netkb-card-container');
if (!container) return;
empty(container);
const visible = data.filter(i => showNotAlive || i.alive);
if (visible.length === 0) {
container.appendChild(el('div', { class: 'netkb-empty' }, [t('common.noData')]));
return;
}
for (const item of visible) {
const alive = item.alive;
const cardClass = `card ${viewMode === 'list' ? 'list' : ''} ${alive ? 'alive' : 'not-alive'}`;
const title = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A');
const sections = [];
if (item.ip) sections.push(fieldRow('IP', 'ip', item.ip));
if (item.mac) sections.push(fieldRow('MAC', 'mac', item.mac));
if (item.vendor && item.vendor !== 'N/A') sections.push(fieldRow('Vendor', 'vendor', item.vendor));
if (item.essid && item.essid !== 'N/A') sections.push(fieldRow('ESSID', 'essid', item.essid));
if (item.ports && item.ports.filter(Boolean).length > 0) {
sections.push(el('div', { class: 'card-section' }, [
el('strong', {}, [L('netkb.openPorts', 'Open Ports') + ':']),
el('div', { class: 'port-bubbles' },
item.ports.filter(Boolean).map(p => chip('port', String(p)))
),
]));
}
container.appendChild(el('div', { class: cardClass }, [
el('div', { class: 'card-content' }, [
el('h3', { class: 'card-title' }, [hlText(title)]),
...sections,
]),
el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip)),
]));
}
}
/* ── table rendering ── */
function renderTable(data) {
const container = $('#netkb-table-container');
if (!container) return;
empty(container);
const thClick = (key) => () => sortBy(key);
const fClick = (crit) => (e) => filterBy(crit, e);
const thead = el('thead', {}, [
el('tr', {}, [
el('th', { onclick: thClick('hostname') }, [t('common.hostname') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('toggleAlive'), title: 'Toggle offline', alt: 'Filter' })]),
el('th', { onclick: thClick('ip') }, ['IP']),
el('th', { onclick: thClick('mac') }, ['MAC']),
el('th', { onclick: thClick('essid') }, ['ESSID']),
el('th', { onclick: thClick('vendor') }, [t('common.vendor')]),
el('th', { onclick: thClick('ports') }, [t('common.ports') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasPorts'), title: 'Has ports', alt: 'Filter' })]),
el('th', {}, [t('common.actions') + ' ',
el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasActions'), title: 'Has actions', alt: 'Filter' })]),
]),
]);
const visible = data.filter(i => showNotAlive || i.alive);
const rows = visible.map(item => {
const hostText = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A');
return el('tr', {}, [
el('td', {}, [chip('host', hostText)]),
el('td', {}, item.ip ? [chip('ip', item.ip)] : ['N/A']),
el('td', {}, item.mac ? [chip('mac', item.mac)] : ['N/A']),
el('td', {}, (item.essid && item.essid !== 'N/A') ? [chip('essid', item.essid)] : ['N/A']),
el('td', {}, (item.vendor && item.vendor !== 'N/A') ? [chip('vendor', item.vendor)] : ['N/A']),
el('td', {}, [el('div', { class: 'port-bubbles' },
(item.ports || []).filter(Boolean).map(p => chip('port', String(p))))]),
el('td', {}, [el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip))]),
]);
});
container.appendChild(el('div', { class: 'table-inner' }, [
el('table', {}, [thead, el('tbody', {}, rows)]),
]));
}
/* ── action badges ── */
function renderBadges(actions, ip) {
if (!actions || actions.length === 0) return [];
const parseRaw = (raw) => {
const m = /^([a-z_]+)_(\d{8})_(\d{6})$/i.exec(raw || '');
if (!m) return null;
const s = m[1].toLowerCase();
const y = m[2].slice(0, 4), mo = m[2].slice(4, 6), d = m[2].slice(6, 8);
const hh = m[3].slice(0, 2), mm = m[3].slice(2, 4), ss = m[3].slice(4, 6);
const ts = Date.parse(`${y}-${mo}-${d}T${hh}:${mm}:${ss}Z`) || 0;
return { status: s, ts, d, mo, y, hh, mm, ss };
};
const map = new Map();
for (const a of actions) {
if (!a || !a.name || !a.status) continue;
const p = parseRaw(a.status);
if (!p) continue;
const prev = map.get(a.name);
if (!prev || p.ts > prev.parsed.ts) map.set(a.name, { ...a, parsed: p });
}
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const label = s => ({ success: 'Success', failed: 'Failed', fail: 'Failed', running: 'Running', pending: 'Pending', expired: 'Expired', cancelled: 'Cancelled' })[s] || s;
return Array.from(map.values())
.sort((a, b) => b.parsed.ts - a.parsed.ts)
.map(a => {
const s = a.parsed.status === 'fail' ? 'failed' : a.parsed.status;
const clickable = ['success', 'failed', 'expired', 'cancelled'].includes(s);
const date = `${a.parsed.d} ${MONTHS[parseInt(a.parsed.mo) - 1] || ''} ${a.parsed.y}`;
const time = `${a.parsed.hh}:${a.parsed.mm}:${a.parsed.ss}`;
return el('div', {
class: `badge ${s} ${clickable ? 'clickable' : ''}`,
...(clickable ? {
onclick: () => {
if (!confirm(L('netkb.confirmRemoveAction', `Are you sure you want to remove the action "${a.name}" for IP "${ip}"?`, { action: a.name, ip }))) return;
removeAction(ip, a.name);
}
} : {}),
}, [
el('div', { class: 'badge-header' }, [hlText(a.name)]),
el('div', { class: 'badge-status' }, [label(s)]),
el('div', { class: 'badge-timestamp' }, [el('div', {}, [date]), el('div', {}, [`at ${time}`])]),
]);
});
}
async function removeAction(ip, action) {
try {
const result = await api.post('/delete_netkb_action', { ip, action });
if (result.status === 'success') {
toast(result.message || t('netkb.actionRemoved'), 2600, 'success');
await refresh();
} else throw new Error(result.message || 'Failed');
} catch (e) {
console.error(e);
toast(`${t('common.error')}: ${e.message}`, 3000, 'error');
}
}
/* ── helpers ── */
function chip(type, text) {
return el('span', { class: `chip ${type}` }, [hlText(text)]);
}
function fieldRow(label, chipType, value) {
return el('div', { class: 'card-section' }, [
el('strong', {}, [`${label}:`]),
el('span', {}, [' ']),
chip(chipType, value),
]);
}
function hlText(text) {
if (!searchTerm || !text) return String(text ?? '');
const str = String(text);
const lower = str.toLowerCase();
const idx = lower.indexOf(searchTerm);
if (idx === -1) return str;
const frag = document.createDocumentFragment();
let pos = 0;
let i = lower.indexOf(searchTerm, pos);
while (i !== -1) {
if (i > pos) frag.appendChild(document.createTextNode(str.slice(pos, i)));
const mark = document.createElement('mark');
mark.className = 'hl';
mark.textContent = str.slice(i, i + searchTerm.length);
frag.appendChild(mark);
pos = i + searchTerm.length;
i = lower.indexOf(searchTerm, pos);
}
if (pos < str.length) frag.appendChild(document.createTextNode(str.slice(pos)));
return frag;
}
function isMobile() { return window.matchMedia('(max-width: 720px)').matches; }