Files
NetTrak/app/static/app.js
T
Keith Solomon 98a424b929
Release / Build and Push Docker Image (push) Successful in 53s
Update sidebar
2026-04-18 12:20:33 -05:00

421 lines
14 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;
let selectedDevice = null;
let saveInFlight = false;
let activeSectionOpen = true;
let inactiveSectionOpen = false;
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 compareDevices(a, b) {
const aName = String(a.hostname || '').trim().toLocaleLowerCase();
const bName = String(b.hostname || '').trim().toLocaleLowerCase();
if (aName && bName) {
const byName = aName.localeCompare(bName);
if (byName !== 0) return byName;
return compareIpNumeric(a, b);
}
if (aName) return -1;
if (bName) return 1;
return compareIpNumeric(a, b);
}
function renderDeviceList() {
deviceListEl.innerHTML = '';
if (!devices.length) {
deviceListEl.innerHTML = '<li class="device-item">No devices discovered yet.</li>';
return;
}
const activeDevices = devices.filter((device) => device.is_active);
const inactiveDevices = devices.filter((device) => !device.is_active);
deviceListEl.appendChild(createSection('Active Machines', activeDevices, activeSectionOpen, true));
deviceListEl.appendChild(createSection('Inactive Machines', inactiveDevices, inactiveSectionOpen, false));
attachDeviceListHandlers();
}
function createSection(title, sectionDevices, isOpen, isActiveSection) {
const sectionItem = document.createElement('li');
sectionItem.className = 'device-section-item';
const details = document.createElement('details');
details.className = 'device-section';
details.dataset.section = isActiveSection ? 'active' : 'inactive';
details.open = isOpen;
const itemsHtml = sectionDevices.length
? sectionDevices.map((device) => `
<li class="device-item ${selectedDeviceId === device.id ? 'active' : ''}" data-device-id="${device.id}">
<div>${escapeHtml(deviceTitle(device))}</div>
<div class="meta">${escapeHtml(device.os_name || 'OS unknown')} | ${device.is_active ? 'Active' : 'Missing'}</div>
</li>
`).join('')
: '<li class="device-empty">No machines in this section.</li>';
details.innerHTML = `
<summary class="device-section-summary">
<span>${escapeHtml(title)}</span>
<span class="device-section-count">${sectionDevices.length}</span>
</summary>
<ul class="device-section-list">${itemsHtml}</ul>
`;
sectionItem.appendChild(details);
return sectionItem;
}
function attachDeviceListHandlers() {
deviceListEl.querySelectorAll('.device-item[data-device-id]').forEach((item) => {
item.addEventListener('click', () => {
const deviceId = Number(item.dataset.deviceId);
if (!deviceId) return;
selectedDeviceId = deviceId;
renderDeviceList();
loadDevice(deviceId);
});
});
const activeSection = deviceListEl.querySelector('.device-section[data-section="active"]');
const inactiveSection = deviceListEl.querySelector('.device-section[data-section="inactive"]');
if (activeSection) {
activeSection.addEventListener('toggle', () => {
activeSectionOpen = activeSection.open;
});
}
if (inactiveSection) {
inactiveSection.addEventListener('toggle', () => {
inactiveSectionOpen = inactiveSection.open;
});
}
}
function renderMachineInfo(d) {
selectedDevice = d;
machineInfoEl.classList.remove('empty');
machineInfoEl.innerHTML = `
<form id="machineInfoForm" class="machine-info-form">
<div class="info-grid">
<div class="info-card info-card-edit">
<div class="label">Hostname</div>
<input id="hostnameInput" class="inline-input" type="text" value="${escapeAttr(d.hostname || '')}" placeholder="${escapeAttr(d.detected_hostname || 'Hostname')}" />
<div class="subvalue">Detected: ${escapeHtml(d.detected_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 info-card-edit">
<div class="label">Operating System</div>
<input id="osNameInput" class="inline-input" type="text" value="${escapeAttr(d.os_name || '')}" placeholder="${escapeAttr(d.detected_os_name || 'Operating system')}" />
<div class="subvalue">Detected: ${escapeHtml(d.detected_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>
<div class="machine-info-actions">
<button id="saveDeviceBtn" type="submit" class="secondary-btn">Save Changes</button>
<button id="resetDeviceBtn" type="button" class="ghost-btn">Reset to Detected</button>
</div>
</form>
`;
const form = document.getElementById('machineInfoForm');
const resetBtn = document.getElementById('resetDeviceBtn');
form.addEventListener('submit', saveDeviceEdits);
resetBtn.addEventListener('click', resetDeviceEdits);
}
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(compareDevices);
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 || []);
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttr(value) {
return escapeHtml(value);
}
async function saveDeviceEdits(event) {
event.preventDefault();
if (!selectedDevice || saveInFlight) return;
const hostname = document.getElementById('hostnameInput')?.value ?? '';
const osName = document.getElementById('osNameInput')?.value ?? '';
const saveBtn = document.getElementById('saveDeviceBtn');
saveInFlight = true;
if (saveBtn) saveBtn.disabled = true;
setStatus(`Saving edits for ${selectedDevice.ip}...`);
try {
const updated = await api(`/api/devices/${selectedDevice.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, os_name: osName }),
});
selectedDevice = updated;
updateDeviceInList(updated);
renderMachineInfo(updated);
setStatus(`Saved hostname and operating system for ${updated.ip}.`);
} catch (err) {
setStatus(`Failed saving device changes: ${err.message}`);
} finally {
saveInFlight = false;
if (saveBtn) saveBtn.disabled = false;
}
}
function resetDeviceEdits() {
if (!selectedDevice) return;
const hostnameInput = document.getElementById('hostnameInput');
const osNameInput = document.getElementById('osNameInput');
if (hostnameInput) hostnameInput.value = '';
if (osNameInput) osNameInput.value = '';
setStatus('Manual overrides cleared in the form. Save to apply.');
}
function updateDeviceInList(updatedDevice) {
devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device));
devices.sort(compareDevices);
renderDeviceList();
}
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);