diff --git a/app/main.py b/app/main.py index 68da1bc..3d7a6cd 100644 --- a/app/main.py +++ b/app/main.py @@ -37,10 +37,12 @@ def home() -> FileResponse: @app.get("/api/health") def health() -> dict[str, Any]: + progress = scan_state.snapshot() return { "status": "ok", - "scan_running": scan_state.running, - "current_scan_id": scan_state.current_scan_id, + "scan_running": progress["running"], + "current_scan_id": progress["scan_id"], + "scan_progress": progress, } @@ -66,7 +68,7 @@ def api_scans(limit: int = 20) -> list[dict]: def run_scan(subnet: str | None = None) -> dict[str, Any]: subnet = subnet or DEFAULT_SUBNET scan_id = create_scan(subnet) - if not scan_state.start(scan_id): + if not scan_state.start(scan_id, subnet): complete_scan(scan_id, "cancelled", 0, notes="Another scan was already running") raise HTTPException(status_code=409, detail="Scan already running") @@ -74,9 +76,13 @@ def run_scan(subnet: str | None = None) -> dict[str, Any]: host_count = 0 try: discovered = discover_hosts(subnet) - for host in discovered: + scan_state.set_total_hosts(len(discovered)) + + for idx, host in enumerate(discovered, start=1): + scan_state.set_current_host(host["ip"]) detailed = scan_host(host["ip"]) if not detailed: + scan_state.update_progress(idx, host_count) continue host_count += 1 if not detailed.hostname and host.get("hostname"): @@ -87,6 +93,7 @@ def run_scan(subnet: str | None = None) -> dict[str, Any]: detailed.vendor = host["vendor"] upsert_host(scan_id, detailed) + scan_state.update_progress(idx, host_count) mark_missing_devices(scan_id) complete_scan(scan_id, "completed", host_count) diff --git a/app/service.py b/app/service.py index 2c7d6a6..529e686 100644 --- a/app/service.py +++ b/app/service.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import threading +import time from datetime import UTC, datetime from .db import get_conn @@ -13,19 +14,73 @@ class ScanState: self._lock = threading.Lock() self.running = False self.current_scan_id: int | None = None + self.subnet: str | None = None + self.total_hosts = 0 + self.processed_hosts = 0 + self.saved_hosts = 0 + self.current_host: str | None = None + self.started_monotonic: float | None = None - def start(self, scan_id: int) -> bool: + def start(self, scan_id: int, subnet: str) -> bool: with self._lock: if self.running: return False self.running = True self.current_scan_id = scan_id + self.subnet = subnet + self.total_hosts = 0 + self.processed_hosts = 0 + self.saved_hosts = 0 + self.current_host = None + self.started_monotonic = time.monotonic() return True + def set_total_hosts(self, total_hosts: int) -> None: + with self._lock: + self.total_hosts = max(total_hosts, 0) + + def set_current_host(self, host_ip: str | None) -> None: + with self._lock: + self.current_host = host_ip + + def update_progress(self, processed_hosts: int, saved_hosts: int) -> None: + with self._lock: + self.processed_hosts = max(processed_hosts, 0) + self.saved_hosts = max(saved_hosts, 0) + def finish(self) -> None: with self._lock: self.running = False self.current_scan_id = None + self.current_host = None + self.started_monotonic = None + + def snapshot(self) -> dict: + with self._lock: + percent = 0 + if self.total_hosts > 0: + percent = int((self.processed_hosts / self.total_hosts) * 100) + + elapsed_seconds = 0 + eta_seconds = None + if self.running and self.started_monotonic is not None: + elapsed_seconds = int(max(time.monotonic() - self.started_monotonic, 0)) + if self.processed_hosts > 0 and self.total_hosts > self.processed_hosts: + rate = self.processed_hosts / max(elapsed_seconds, 1) + remaining = self.total_hosts - self.processed_hosts + eta_seconds = int(remaining / max(rate, 1e-9)) + return { + "running": self.running, + "scan_id": self.current_scan_id, + "subnet": self.subnet, + "total_hosts": self.total_hosts, + "processed_hosts": self.processed_hosts, + "saved_hosts": self.saved_hosts, + "current_host": self.current_host, + "percent": min(max(percent, 0), 100), + "elapsed_seconds": elapsed_seconds, + "eta_seconds": eta_seconds, + } scan_state = ScanState() diff --git a/app/static/app.js b/app/static/app.js index 4ce9b70..9a0ae89 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -4,6 +4,11 @@ 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; @@ -12,6 +17,45 @@ 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) { @@ -131,47 +175,73 @@ async function runScan() { const subnet = subnetInput.value.trim(); if (!subnet) return; - scanBtn.disabled = true; 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}`); - } finally { - scanBtn.disabled = false; + await refreshScanProgress(); } } async function pollUntilComplete(scanId) { for (let i = 0; i < 240; i += 1) { - await new Promise((r) => setTimeout(r, 3000)); - const scans = await api('/api/scans?limit=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') { - setStatus(`Scan #${scanId} is 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 loadDevices(); + await Promise.all([loadDevices(), refreshScanProgress()]); setStatus('Ready.'); } catch (err) { setStatus(`Failed loading data: ${err.message}`); } })(); + +setInterval(refreshScanProgress, 3000); diff --git a/app/static/index.html b/app/static/index.html index 0fb7725..01e75ae 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -12,9 +12,21 @@
Network inventory and port intelligence
-