mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-13 08:01:59 +00:00
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.
This commit is contained in:
801
web/js/pages/web-enum.js
Normal file
801
web/js/pages/web-enum.js
Normal file
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* Web Enum page module.
|
||||
* Displays web enumeration/directory brute-force results with filtering,
|
||||
* sorting, pagination, detail modal, and JSON/CSV export.
|
||||
* Endpoint: GET /api/webenum/results?page=N&limit=M
|
||||
*/
|
||||
import { ResourceTracker } from '../core/resource-tracker.js';
|
||||
import { api } from '../core/api.js';
|
||||
import { el, $, $$, empty } from '../core/dom.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
|
||||
const PAGE = 'web-enum';
|
||||
const MAX_PAGES_FETCH = 200;
|
||||
const FETCH_LIMIT = 500;
|
||||
const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500, 0]; // 0 = All
|
||||
const ANSI_RE = /[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
|
||||
/* ── state ── */
|
||||
let tracker = null;
|
||||
let allData = [];
|
||||
let filteredData = [];
|
||||
let currentPage = 1;
|
||||
let itemsPerPage = 50;
|
||||
let sortField = 'scan_date';
|
||||
let sortDirection = 'desc';
|
||||
let exactStatusFilter = null;
|
||||
let serverTotal = 0;
|
||||
let fetchedLimit = false;
|
||||
|
||||
/* filter state */
|
||||
let searchText = '';
|
||||
let filterHost = '';
|
||||
let filterStatusFamily = '';
|
||||
let filterPort = '';
|
||||
let filterDate = '';
|
||||
let searchDebounceId = null;
|
||||
|
||||
/* ── lifecycle ── */
|
||||
export async function mount(container) {
|
||||
tracker = new ResourceTracker(PAGE);
|
||||
container.appendChild(buildShell());
|
||||
await fetchAllData();
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
if (searchDebounceId != null) clearTimeout(searchDebounceId);
|
||||
searchDebounceId = null;
|
||||
if (tracker) { tracker.cleanupAll(); tracker = null; }
|
||||
allData = [];
|
||||
filteredData = [];
|
||||
currentPage = 1;
|
||||
itemsPerPage = 50;
|
||||
sortField = 'scan_date';
|
||||
sortDirection = 'desc';
|
||||
exactStatusFilter = null;
|
||||
serverTotal = 0;
|
||||
fetchedLimit = false;
|
||||
searchText = '';
|
||||
filterHost = '';
|
||||
filterStatusFamily = '';
|
||||
filterPort = '';
|
||||
filterDate = '';
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Shell
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
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)'),
|
||||
]),
|
||||
/* controls row */
|
||||
el('div', { class: 'webenum-controls' }, [
|
||||
/* text search */
|
||||
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',
|
||||
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']),
|
||||
]),
|
||||
/* 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),
|
||||
el('input', {
|
||||
type: 'date', class: 'webenum-date-input', id: 'we-filter-date',
|
||||
onchange: onDateFilter,
|
||||
}),
|
||||
]),
|
||||
/* 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']),
|
||||
]),
|
||||
]),
|
||||
/* status legend chips */
|
||||
el('div', { class: 'webenum-status-legend', id: 'we-status-legend' }),
|
||||
/* table container */
|
||||
el('div', { class: 'webenum-table-wrap', id: 'we-table-wrap' }),
|
||||
/* pagination */
|
||||
el('div', { class: 'webenum-pagination', id: 'we-pagination' }),
|
||||
/* detail modal */
|
||||
el('div', { class: 'vuln-modal', id: 'we-modal', onclick: onModalBackdrop }, [
|
||||
el('div', { class: 'vuln-modal-content' }, [
|
||||
el('div', { class: 'vuln-modal-header' }, [
|
||||
el('span', { class: 'vuln-modal-title', id: 'we-modal-title' }),
|
||||
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
|
||||
]),
|
||||
el('div', { class: 'vuln-modal-body', id: 'we-modal-body' }),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function statItem(id, label) {
|
||||
return el('div', { class: 'stat-item' }, [
|
||||
el('span', { class: 'stat-value', id }, ['0']),
|
||||
el('span', { class: 'stat-label' }, [label]),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildSelect(id, defaultLabel, handler) {
|
||||
return el('select', { class: 'webenum-filter-select', id, onchange: handler }, [
|
||||
el('option', { value: '' }, [defaultLabel]),
|
||||
]);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Data fetching — paginate through all server pages
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
async function fetchAllData() {
|
||||
const loading = $('#we-table-wrap');
|
||||
if (loading) {
|
||||
empty(loading);
|
||||
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026']));
|
||||
}
|
||||
|
||||
const ac = tracker ? tracker.trackAbortController() : null;
|
||||
const signal = ac ? ac.signal : undefined;
|
||||
|
||||
let accumulated = [];
|
||||
let page = 1;
|
||||
serverTotal = 0;
|
||||
fetchedLimit = false;
|
||||
|
||||
try {
|
||||
while (page <= MAX_PAGES_FETCH) {
|
||||
const url = `/api/webenum/results?page=${page}&limit=${FETCH_LIMIT}`;
|
||||
const data = await api.get(url, { signal, timeout: 15000 });
|
||||
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
if (data.total != null) serverTotal = data.total;
|
||||
|
||||
if (results.length === 0) break;
|
||||
|
||||
accumulated = accumulated.concat(results);
|
||||
|
||||
/* all fetched */
|
||||
if (serverTotal > 0 && accumulated.length >= serverTotal) break;
|
||||
/* page was not full — last page */
|
||||
if (results.length < FETCH_LIMIT) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (page > MAX_PAGES_FETCH) fetchedLimit = true;
|
||||
} catch (err) {
|
||||
if (err.name === 'ApiError' && err.message === 'Aborted') return;
|
||||
console.warn(`[${PAGE}] fetch error:`, err.message);
|
||||
} finally {
|
||||
if (ac && tracker) tracker.removeAbortController(ac);
|
||||
}
|
||||
|
||||
allData = accumulated.map(normalizeRow);
|
||||
populateFilterDropdowns();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
/* ── row normalization ── */
|
||||
function normalizeRow(row) {
|
||||
const host = (row.host || row.hostname || '').toString();
|
||||
let directory = (row.directory || '').toString().replace(ANSI_RE, '');
|
||||
return {
|
||||
id: row.id,
|
||||
host: host,
|
||||
ip: (row.ip || '').toString(),
|
||||
mac: (row.mac || '').toString(),
|
||||
port: row.port != null ? Number(row.port) : 0,
|
||||
directory: directory,
|
||||
status: row.status != null ? Number(row.status) : 0,
|
||||
size: row.size != null ? Number(row.size) : 0,
|
||||
scan_date: row.scan_date || '',
|
||||
response_time: row.response_time != null ? Number(row.response_time) : 0,
|
||||
content_type: (row.content_type || '').toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Filter dropdowns — populate from unique values
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function populateFilterDropdowns() {
|
||||
populateSelect('we-filter-host', 'All Hosts',
|
||||
[...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);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function populateSelect(id, defaultLabel, options) {
|
||||
const sel = $(`#${id}`);
|
||||
if (!sel) return;
|
||||
const current = sel.value;
|
||||
empty(sel);
|
||||
sel.appendChild(el('option', { value: '' }, [defaultLabel]));
|
||||
options.forEach(opt => {
|
||||
sel.appendChild(el('option', { value: opt }, [opt]));
|
||||
});
|
||||
if (current && options.includes(current)) sel.value = current;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Filter & sort pipeline
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function applyFilters() {
|
||||
const needle = searchText.toLowerCase();
|
||||
|
||||
filteredData = allData.filter(row => {
|
||||
/* exact status chip filter */
|
||||
if (exactStatusFilter != null && row.status !== exactStatusFilter) return false;
|
||||
|
||||
/* text search */
|
||||
if (needle) {
|
||||
const hay = `${row.host} ${row.ip} ${row.directory} ${row.status}`.toLowerCase();
|
||||
if (!hay.includes(needle)) return false;
|
||||
}
|
||||
|
||||
/* host dropdown */
|
||||
if (filterHost && row.host !== filterHost) return false;
|
||||
|
||||
/* status family dropdown */
|
||||
if (filterStatusFamily && statusFamily(row.status) !== filterStatusFamily) return false;
|
||||
|
||||
/* port dropdown */
|
||||
if (filterPort && String(row.port) !== filterPort) return false;
|
||||
|
||||
/* date filter */
|
||||
if (filterDate) {
|
||||
const rowDate = (row.scan_date || '').substring(0, 10);
|
||||
if (rowDate !== filterDate) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
applySort();
|
||||
currentPage = 1;
|
||||
updateStats();
|
||||
renderStatusLegend();
|
||||
renderTable();
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
const dir = sortDirection === 'asc' ? 1 : -1;
|
||||
const field = sortField;
|
||||
|
||||
filteredData.sort((a, b) => {
|
||||
let va = a[field];
|
||||
let vb = b[field];
|
||||
|
||||
if (va == null) va = '';
|
||||
if (vb == null) vb = '';
|
||||
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return (va - vb) * dir;
|
||||
}
|
||||
|
||||
/* date string comparison */
|
||||
if (field === 'scan_date') {
|
||||
const da = new Date(va).getTime() || 0;
|
||||
const db = new Date(vb).getTime() || 0;
|
||||
return (da - db) * dir;
|
||||
}
|
||||
|
||||
return String(va).localeCompare(String(vb)) * dir;
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Stats bar
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function updateStats() {
|
||||
const totalLabel = fetchedLimit
|
||||
? `${filteredData.length} (truncated)`
|
||||
: String(filteredData.length);
|
||||
setStatVal('we-stat-total', totalLabel);
|
||||
setStatVal('we-stat-hosts', new Set(filteredData.map(r => r.host || r.ip)).size);
|
||||
setStatVal('we-stat-success', filteredData.filter(r => r.status >= 200 && r.status < 300).length);
|
||||
setStatVal('we-stat-errors', filteredData.filter(r => r.status >= 400).length);
|
||||
}
|
||||
|
||||
function setStatVal(id, val) {
|
||||
const e = $(`#${id}`);
|
||||
if (e) e.textContent = String(val);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Status legend chips
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function renderStatusLegend() {
|
||||
const container = $('#we-status-legend');
|
||||
if (!container) return;
|
||||
empty(container);
|
||||
|
||||
/* gather unique status codes from current allData (unfiltered view) */
|
||||
const codes = [...new Set(allData.map(r => r.status))].sort((a, b) => a - b);
|
||||
if (codes.length === 0) return;
|
||||
|
||||
codes.forEach(code => {
|
||||
const count = allData.filter(r => r.status === code).length;
|
||||
const isActive = exactStatusFilter === code;
|
||||
const chip = el('span', {
|
||||
class: `webenum-status-chip ${statusClass(code)} ${isActive ? 'active' : ''}`,
|
||||
onclick: () => {
|
||||
if (exactStatusFilter === code) {
|
||||
exactStatusFilter = null;
|
||||
} else {
|
||||
exactStatusFilter = code;
|
||||
}
|
||||
/* clear active class on all chips, re-apply via full filter cycle */
|
||||
$$('.webenum-status-chip', container).forEach(c => c.classList.remove('active'));
|
||||
applyFilters();
|
||||
},
|
||||
}, [`${code} (${count})`]);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Table rendering
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function renderTable() {
|
||||
const wrap = $('#we-table-wrap');
|
||||
if (!wrap) return;
|
||||
empty(wrap);
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
wrap.appendChild(emptyState('No web enumeration results found'));
|
||||
return;
|
||||
}
|
||||
|
||||
/* current page slice */
|
||||
const pageData = getPageSlice();
|
||||
|
||||
/* 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' },
|
||||
];
|
||||
|
||||
/* thead */
|
||||
const headerCells = columns.map(col => {
|
||||
if (col.key === '_actions') {
|
||||
return el('th', {}, [col.label]);
|
||||
}
|
||||
const isSorted = sortField === col.key;
|
||||
const arrow = isSorted ? (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC') : '';
|
||||
return el('th', {
|
||||
class: `sortable ${isSorted ? 'sort-' + sortDirection : ''}`,
|
||||
style: 'cursor:pointer;user-select:none;',
|
||||
onclick: () => onSortColumn(col.key),
|
||||
}, [col.label + arrow]);
|
||||
});
|
||||
|
||||
const thead = el('thead', {}, [el('tr', {}, headerCells)]);
|
||||
|
||||
/* tbody */
|
||||
const rows = pageData.map(row => {
|
||||
const url = buildUrl(row);
|
||||
return el('tr', {
|
||||
class: 'webenum-row',
|
||||
style: 'cursor:pointer;',
|
||||
onclick: (e) => {
|
||||
/* ignore if click was on an anchor */
|
||||
if (e.target.tagName === 'A') return;
|
||||
showDetailModal(row);
|
||||
},
|
||||
}, [
|
||||
el('td', {}, [row.host || '-']),
|
||||
el('td', {}, [row.ip || '-']),
|
||||
el('td', {}, [row.port ? String(row.port) : '-']),
|
||||
el('td', { class: 'webenum-dir-cell', title: row.directory }, [row.directory || '/']),
|
||||
el('td', {}, [statusBadge(row.status)]),
|
||||
el('td', {}, [formatSize(row.size)]),
|
||||
el('td', {}, [formatDate(row.scan_date)]),
|
||||
el('td', {}, [
|
||||
url
|
||||
? el('a', {
|
||||
href: url, target: '_blank', rel: 'noopener noreferrer',
|
||||
class: 'webenum-link', title: url,
|
||||
onclick: (e) => e.stopPropagation(),
|
||||
}, ['Open'])
|
||||
: el('span', { class: 'muted' }, ['-']),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
const tbody = el('tbody', {}, rows);
|
||||
const table = el('table', { class: 'webenum-table' }, [thead, tbody]);
|
||||
wrap.appendChild(el('div', { class: 'table-inner' }, [table]));
|
||||
}
|
||||
|
||||
function getPageSlice() {
|
||||
if (itemsPerPage === 0) return filteredData; // All
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return filteredData.slice(start, start + itemsPerPage);
|
||||
}
|
||||
|
||||
function getTotalPages() {
|
||||
if (itemsPerPage === 0) return 1;
|
||||
return Math.max(1, Math.ceil(filteredData.length / itemsPerPage));
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Pagination
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function renderPagination() {
|
||||
const pag = $('#we-pagination');
|
||||
if (!pag) return;
|
||||
empty(pag);
|
||||
|
||||
const total = getTotalPages();
|
||||
|
||||
/* 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 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:']),
|
||||
perPageSel,
|
||||
]));
|
||||
|
||||
if (total <= 1 && itemsPerPage !== 0) {
|
||||
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
||||
`${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemsPerPage === 0) {
|
||||
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
||||
`Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Prev */
|
||||
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
|
||||
|
||||
/* numbered buttons */
|
||||
const start = Math.max(1, currentPage - 2);
|
||||
const end = Math.min(total, start + 4);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
|
||||
}
|
||||
|
||||
/* Next */
|
||||
pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1)));
|
||||
|
||||
/* info */
|
||||
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
||||
`Page ${currentPage} of ${total} (${filteredData.length} results)`,
|
||||
]));
|
||||
}
|
||||
|
||||
function pageBtn(label, enabled, onclick, active = false) {
|
||||
return el('button', {
|
||||
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
|
||||
onclick: enabled ? onclick : null,
|
||||
disabled: !enabled,
|
||||
}, [label]);
|
||||
}
|
||||
|
||||
function changePage(p) {
|
||||
const total = getTotalPages();
|
||||
currentPage = Math.max(1, Math.min(total, p));
|
||||
renderTable();
|
||||
renderPagination();
|
||||
const wrap = $('#we-table-wrap');
|
||||
if (wrap) wrap.scrollTop = 0;
|
||||
}
|
||||
|
||||
function onPerPageChange(e) {
|
||||
itemsPerPage = parseInt(e.target.value, 10);
|
||||
currentPage = 1;
|
||||
renderTable();
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Sort handler
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function onSortColumn(key) {
|
||||
if (sortField === key) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = key;
|
||||
sortDirection = 'asc';
|
||||
}
|
||||
applySort();
|
||||
renderTable();
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Filter handlers
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function onSearchInput(e) {
|
||||
if (searchDebounceId != null) clearTimeout(searchDebounceId);
|
||||
const val = e.target.value;
|
||||
searchDebounceId = tracker
|
||||
? tracker.trackTimeout(() => {
|
||||
searchText = val;
|
||||
applyFilters();
|
||||
const btn = e.target.nextElementSibling;
|
||||
if (btn) btn.classList.toggle('show', val.length > 0);
|
||||
}, 300)
|
||||
: setTimeout(() => {
|
||||
searchText = val;
|
||||
applyFilters();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
const inp = $('#we-search');
|
||||
if (inp) inp.value = '';
|
||||
searchText = '';
|
||||
applyFilters();
|
||||
const btn = inp ? inp.nextElementSibling : null;
|
||||
if (btn) btn.classList.remove('show');
|
||||
}
|
||||
|
||||
function onHostFilter(e) {
|
||||
filterHost = e.target.value;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function onStatusFamilyFilter(e) {
|
||||
filterStatusFamily = e.target.value;
|
||||
/* clear exact chip filter when dropdown changes */
|
||||
exactStatusFilter = null;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function onPortFilter(e) {
|
||||
filterPort = e.target.value;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function onDateFilter(e) {
|
||||
filterDate = e.target.value || '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Detail modal
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function showDetailModal(row) {
|
||||
const modal = $('#we-modal');
|
||||
const title = $('#we-modal-title');
|
||||
const body = $('#we-modal-body');
|
||||
if (!modal || !title || !body) return;
|
||||
|
||||
const url = buildUrl(row);
|
||||
|
||||
title.textContent = `${row.host || row.ip}${row.directory || '/'}`;
|
||||
empty(body);
|
||||
|
||||
const fields = [
|
||||
['Host', row.host],
|
||||
['IP', row.ip],
|
||||
['MAC', row.mac],
|
||||
['Port', row.port],
|
||||
['Directory', row.directory],
|
||||
['Status', row.status],
|
||||
['Size', formatSize(row.size)],
|
||||
['Content-Type', row.content_type],
|
||||
['Response Time', row.response_time ? row.response_time + ' ms' : '-'],
|
||||
['Scan Date', formatDate(row.scan_date)],
|
||||
['URL', url || 'N/A'],
|
||||
];
|
||||
|
||||
fields.forEach(([label, value]) => {
|
||||
body.appendChild(el('div', { class: 'modal-detail-section' }, [
|
||||
el('div', { class: 'modal-section-title' }, [label]),
|
||||
el('div', { class: 'modal-section-text' }, [
|
||||
label === 'Status'
|
||||
? statusBadge(value)
|
||||
: String(value != null ? value : '-'),
|
||||
]),
|
||||
]));
|
||||
});
|
||||
|
||||
/* action buttons */
|
||||
const actions = el('div', { class: 'webenum-modal-actions' }, []);
|
||||
|
||||
if (url) {
|
||||
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}, ['Open URL']));
|
||||
|
||||
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
|
||||
copyText(url);
|
||||
}}, ['Copy URL']));
|
||||
}
|
||||
|
||||
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']));
|
||||
|
||||
body.appendChild(actions);
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = $('#we-modal');
|
||||
if (modal) modal.classList.remove('show');
|
||||
}
|
||||
|
||||
function onModalBackdrop(e) {
|
||||
if (e.target.classList.contains('vuln-modal')) closeModal();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Export — JSON & CSV
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
function exportData(format) {
|
||||
const data = filteredData.length > 0 ? filteredData : allData;
|
||||
if (data.length === 0) return;
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
if (format === 'json') {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
downloadBlob(json, `webenum_results_${dateStr}.json`, 'application/json');
|
||||
} else {
|
||||
const csv = buildCSV(data);
|
||||
downloadBlob(csv, `webenum_results_${dateStr}.csv`, 'text/csv');
|
||||
}
|
||||
}
|
||||
|
||||
function exportSingleResult(row, format) {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
if (format === 'json') {
|
||||
downloadBlob(JSON.stringify(row, null, 2), `webenum_${row.host}_${dateStr}.json`, 'application/json');
|
||||
} else {
|
||||
downloadBlob(buildCSV([row]), `webenum_${row.host}_${dateStr}.csv`, 'text/csv');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCSV(data) {
|
||||
const headers = ['Host', 'IP', 'MAC', 'Port', 'Directory', 'Status', 'Size', 'Content-Type', 'Response Time', 'Scan Date', 'URL'];
|
||||
const rows = [headers.join(',')];
|
||||
data.forEach(r => {
|
||||
const url = buildUrl(r) || '';
|
||||
const values = [
|
||||
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 : '');
|
||||
return s.includes(',') || s.includes('"') || s.includes('\n')
|
||||
? `"${s.replace(/"/g, '""')}"` : s;
|
||||
});
|
||||
rows.push(values.join(','));
|
||||
});
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
function downloadBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Helpers
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** Status family string: '2xx', '3xx', '4xx', '5xx' */
|
||||
function statusFamily(code) {
|
||||
code = Number(code) || 0;
|
||||
if (code >= 200 && code < 300) return '2xx';
|
||||
if (code >= 300 && code < 400) return '3xx';
|
||||
if (code >= 400 && code < 500) return '4xx';
|
||||
if (code >= 500) return '5xx';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** CSS class for status code */
|
||||
function statusClass(code) {
|
||||
code = Number(code) || 0;
|
||||
if (code >= 200 && code < 300) return 'status-2xx';
|
||||
if (code >= 300 && code < 400) return 'status-3xx';
|
||||
if (code >= 400 && code < 500) return 'status-4xx';
|
||||
if (code >= 500) return 'status-5xx';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Status badge element */
|
||||
function statusBadge(code) {
|
||||
return el('span', { class: `webenum-status-badge ${statusClass(code)}` }, [String(code)]);
|
||||
}
|
||||
|
||||
/** Build full URL from row data */
|
||||
function buildUrl(row) {
|
||||
if (!row.host && !row.ip) return '';
|
||||
const hostname = row.host || row.ip;
|
||||
const port = Number(row.port) || 80;
|
||||
const proto = port === 443 ? 'https' : 'http';
|
||||
const portPart = (port === 80 || port === 443) ? '' : `:${port}`;
|
||||
const dir = row.directory || '/';
|
||||
return `${proto}://${hostname}${portPart}${dir}`;
|
||||
}
|
||||
|
||||
/** Format byte size to human-readable */
|
||||
function formatSize(bytes) {
|
||||
bytes = Number(bytes) || 0;
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
/** Format date string */
|
||||
function formatDate(d) {
|
||||
if (!d) return '-';
|
||||
try {
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return String(d);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return String(d);
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty state */
|
||||
function emptyState(msg) {
|
||||
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
|
||||
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\uD83D\uDD0D']),
|
||||
msg,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Copy text to clipboard */
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* noop */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
Reference in New Issue
Block a user