277 lines
9.0 KiB
JavaScript
277 lines
9.0 KiB
JavaScript
const deviceListEl = document.getElementById('deviceList');
|
|
const machineInfoEl = document.getElementById('machineInfo');
|
|
const portsListEl = document.getElementById('portsList');
|
|
const statusText = document.getElementById('statusText');
|
|
const scanBtn = document.getElementById('scanBtn');
|
|
const subnetInput = document.getElementById('subnetInput');
|
|
const scanProgressCard = document.getElementById('scanProgressCard');
|
|
const scanProgressTitle = document.getElementById('scanProgressTitle');
|
|
const scanProgressPercent = document.getElementById('scanProgressPercent');
|
|
const scanProgressFill = document.getElementById('scanProgressFill');
|
|
const scanProgressDetail = document.getElementById('scanProgressDetail');
|
|
|
|
let devices = [];
|
|
let selectedDeviceId = null;
|
|
|
|
function setStatus(msg) {
|
|
statusText.textContent = msg;
|
|
}
|
|
|
|
function formatDuration(totalSeconds) {
|
|
if (totalSeconds == null || Number.isNaN(Number(totalSeconds))) return '-';
|
|
const seconds = Math.max(0, Math.floor(Number(totalSeconds)));
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
|
if (m > 0) return `${m}m ${s}s`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
function renderScanProgress(progress) {
|
|
if (!progress || !progress.running) {
|
|
scanProgressCard.classList.add('idle');
|
|
scanProgressTitle.textContent = 'No Active Scan';
|
|
scanProgressPercent.textContent = '0%';
|
|
scanProgressFill.style.width = '0%';
|
|
scanProgressDetail.textContent = 'Start a scan to see live progress.';
|
|
scanBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
const total = Number(progress.total_hosts || 0);
|
|
const processed = Number(progress.processed_hosts || 0);
|
|
const saved = Number(progress.saved_hosts || 0);
|
|
const percent = Number(progress.percent || 0);
|
|
const current = progress.current_host || 'waiting for next host';
|
|
const totalLabel = total > 0 ? total : '?';
|
|
const etaLabel = progress.eta_seconds == null ? 'calculating...' : formatDuration(progress.eta_seconds);
|
|
const elapsedLabel = formatDuration(progress.elapsed_seconds || 0);
|
|
|
|
scanProgressCard.classList.remove('idle');
|
|
scanProgressTitle.textContent = `Scan #${progress.scan_id} Running`;
|
|
scanProgressPercent.textContent = `${percent}%`;
|
|
scanProgressFill.style.width = `${percent}%`;
|
|
scanProgressDetail.textContent = `Processed ${processed}/${totalLabel} hosts | Saved ${saved} | ETA ${etaLabel} | Elapsed ${elapsedLabel} | Current ${current}`;
|
|
scanBtn.disabled = true;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const res = await fetch(path, options);
|
|
if (!res.ok) {
|
|
let detail = `${res.status}`;
|
|
try {
|
|
const data = await res.json();
|
|
if (data.detail) detail = data.detail;
|
|
} catch (_) {}
|
|
throw new Error(detail);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '-';
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
function deviceTitle(d) {
|
|
return d.hostname ? `${d.hostname} (${d.ip})` : d.ip;
|
|
}
|
|
|
|
function parseIPv4(ip) {
|
|
if (typeof ip !== 'string') return null;
|
|
const parts = ip.trim().split('.');
|
|
if (parts.length !== 4) return null;
|
|
const nums = parts.map((p) => Number(p));
|
|
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
return nums;
|
|
}
|
|
|
|
function compareIpNumeric(a, b) {
|
|
const pa = parseIPv4(a.ip);
|
|
const pb = parseIPv4(b.ip);
|
|
|
|
if (pa && pb) {
|
|
for (let i = 0; i < 4; i += 1) {
|
|
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (pa) return -1;
|
|
if (pb) return 1;
|
|
return String(a.ip || '').localeCompare(String(b.ip || ''));
|
|
}
|
|
|
|
function renderDeviceList() {
|
|
deviceListEl.innerHTML = '';
|
|
|
|
if (!devices.length) {
|
|
deviceListEl.innerHTML = '<li class="device-item">No devices discovered yet.</li>';
|
|
return;
|
|
}
|
|
|
|
devices.forEach((d) => {
|
|
const li = document.createElement('li');
|
|
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
|
|
li.innerHTML = `
|
|
<div>${deviceTitle(d)}</div>
|
|
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div>
|
|
`;
|
|
li.addEventListener('click', () => {
|
|
selectedDeviceId = d.id;
|
|
renderDeviceList();
|
|
loadDevice(d.id);
|
|
});
|
|
deviceListEl.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function renderMachineInfo(d) {
|
|
machineInfoEl.classList.remove('empty');
|
|
machineInfoEl.innerHTML = `
|
|
<div class="info-grid">
|
|
<div class="info-card"><div class="label">Hostname</div><div class="value">${d.hostname || '-'}</div></div>
|
|
<div class="info-card"><div class="label">IP Address</div><div class="value">${d.ip}</div></div>
|
|
<div class="info-card"><div class="label">MAC Address</div><div class="value">${d.mac || '-'}</div></div>
|
|
<div class="info-card"><div class="label">Vendor</div><div class="value">${d.vendor || '-'}</div></div>
|
|
<div class="info-card"><div class="label">Operating System</div><div class="value">${d.os_name || '-'}</div></div>
|
|
<div class="info-card"><div class="label">Status</div><div class="value">${d.is_active ? 'Active' : 'Not Seen in Last Scan'}</div></div>
|
|
<div class="info-card"><div class="label">First Seen</div><div class="value">${formatDate(d.first_seen)}</div></div>
|
|
<div class="info-card"><div class="label">Last Seen</div><div class="value">${formatDate(d.last_seen)}</div></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderPorts(ports) {
|
|
portsListEl.innerHTML = '';
|
|
portsListEl.classList.remove('empty');
|
|
|
|
if (!ports.length) {
|
|
portsListEl.classList.add('empty');
|
|
portsListEl.textContent = 'No ports recorded for this machine.';
|
|
return;
|
|
}
|
|
|
|
ports.forEach((p) => {
|
|
const details = document.createElement('details');
|
|
details.className = 'port';
|
|
const svc = [p.service, p.product, p.version].filter(Boolean).join(' ');
|
|
const headers = Object.entries(p.headers || {})
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('\n') || 'No headers captured';
|
|
|
|
details.innerHTML = `
|
|
<summary>${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''}</summary>
|
|
<div class="port-body">
|
|
Service: ${svc || 'Unknown'}
|
|
Extra: ${p.extra_info || '-'}
|
|
Banner: ${p.banner || '-'}
|
|
First Seen: ${formatDate(p.first_seen)}
|
|
Last Seen: ${formatDate(p.last_seen)}
|
|
Headers:\n${headers}
|
|
</div>
|
|
`;
|
|
portsListEl.appendChild(details);
|
|
});
|
|
}
|
|
|
|
async function loadDevices() {
|
|
devices = await api('/api/devices');
|
|
devices.sort((a, b) => {
|
|
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
|
|
return compareIpNumeric(a, b);
|
|
});
|
|
renderDeviceList();
|
|
|
|
if (!selectedDeviceId && devices.length) {
|
|
selectedDeviceId = devices[0].id;
|
|
renderDeviceList();
|
|
}
|
|
|
|
if (selectedDeviceId) {
|
|
await loadDevice(selectedDeviceId);
|
|
}
|
|
}
|
|
|
|
async function loadDevice(deviceId) {
|
|
const d = await api(`/api/devices/${deviceId}`);
|
|
renderMachineInfo(d);
|
|
renderPorts(d.ports || []);
|
|
}
|
|
|
|
async function runScan() {
|
|
const subnet = subnetInput.value.trim();
|
|
if (!subnet) return;
|
|
|
|
setStatus(`Starting scan on ${subnet}...`);
|
|
|
|
try {
|
|
const result = await api(`/api/scans/run?subnet=${encodeURIComponent(subnet)}`, { method: 'POST' });
|
|
renderScanProgress({
|
|
running: true,
|
|
scan_id: result.scan_id,
|
|
total_hosts: 0,
|
|
processed_hosts: 0,
|
|
saved_hosts: 0,
|
|
current_host: null,
|
|
percent: 0,
|
|
});
|
|
setStatus(`Scan #${result.scan_id} running on ${result.subnet}. Refreshing automatically...`);
|
|
await pollUntilComplete(result.scan_id);
|
|
} catch (err) {
|
|
setStatus(`Scan failed to start: ${err.message}`);
|
|
await refreshScanProgress();
|
|
}
|
|
}
|
|
|
|
async function pollUntilComplete(scanId) {
|
|
for (let i = 0; i < 240; i += 1) {
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const [health, scans] = await Promise.all([api('/api/health'), api('/api/scans?limit=1')]);
|
|
renderScanProgress(health.scan_progress);
|
|
|
|
const latest = scans[0];
|
|
if (!latest || latest.id !== scanId) continue;
|
|
|
|
if (latest.status === 'running') {
|
|
const progress = health.scan_progress || {};
|
|
const total = progress.total_hosts || 0;
|
|
const processed = progress.processed_hosts || 0;
|
|
const label = total > 0 ? `${processed}/${total}` : `${processed}/?`;
|
|
const eta = progress.eta_seconds == null ? 'calculating ETA' : `ETA ${formatDuration(progress.eta_seconds)}`;
|
|
setStatus(`Scan #${scanId} is running (${label} hosts processed, ${eta})...`);
|
|
continue;
|
|
}
|
|
|
|
setStatus(`Scan #${scanId} ${latest.status} (${latest.host_count} hosts).`);
|
|
renderScanProgress(null);
|
|
await loadDevices();
|
|
return;
|
|
}
|
|
setStatus(`Scan #${scanId} is taking longer than expected.`);
|
|
}
|
|
|
|
async function refreshScanProgress() {
|
|
try {
|
|
const health = await api('/api/health');
|
|
renderScanProgress(health.scan_progress);
|
|
} catch (_) {
|
|
renderScanProgress(null);
|
|
}
|
|
}
|
|
|
|
scanBtn.addEventListener('click', runScan);
|
|
|
|
(async function init() {
|
|
setStatus('Loading inventory...');
|
|
try {
|
|
await Promise.all([loadDevices(), refreshScanProgress()]);
|
|
setStatus('Ready.');
|
|
} catch (err) {
|
|
setStatus(`Failed loading data: ${err.message}`);
|
|
}
|
|
})();
|
|
|
|
setInterval(refreshScanProgress, 3000);
|