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 = '
  • No devices discovered yet.
  • '; 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) => `
  • ${escapeHtml(deviceTitle(device))}
    ${escapeHtml(device.os_name || 'OS unknown')} | ${device.is_active ? 'Active' : 'Missing'}
  • `).join('') : '
  • No machines in this section.
  • '; details.innerHTML = ` ${escapeHtml(title)} ${sectionDevices.length} `; 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 = `
    Hostname
    Detected: ${escapeHtml(d.detected_hostname || '-')}
    IP Address
    ${d.ip}
    MAC Address
    ${d.mac || '-'}
    Vendor
    ${d.vendor || '-'}
    Operating System
    Detected: ${escapeHtml(d.detected_os_name || '-')}
    Status
    ${d.is_active ? 'Active' : 'Not Seen in Last Scan'}
    First Seen
    ${formatDate(d.first_seen)}
    Last Seen
    ${formatDate(d.last_seen)}
    `; 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 = ` ${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''}
    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}
    `; 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } 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);