✨feature: Scan progress and timing features
This commit is contained in:
15
app/main.py
15
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,9 +12,21 @@
|
||||
<h1>NetTrak</h1>
|
||||
<p>Network inventory and port intelligence</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="subnetInput" type="text" value="192.168.2.0/24" aria-label="Subnet" />
|
||||
<button id="scanBtn">Run Scan</button>
|
||||
<div class="toolbar">
|
||||
<div class="controls">
|
||||
<input id="subnetInput" type="text" value="192.168.2.0/24" aria-label="Subnet" />
|
||||
<button id="scanBtn">Run Scan</button>
|
||||
</div>
|
||||
<div id="scanProgressCard" class="scan-progress-card idle" aria-live="polite">
|
||||
<div class="scan-progress-head">
|
||||
<strong id="scanProgressTitle">No Active Scan</strong>
|
||||
<span id="scanProgressPercent">0%</span>
|
||||
</div>
|
||||
<div class="scan-progress-track">
|
||||
<div id="scanProgressFill" class="scan-progress-fill"></div>
|
||||
</div>
|
||||
<div id="scanProgressDetail" class="scan-progress-detail">Start a scan to see live progress.</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
input, button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -75,6 +81,47 @@ button {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan-progress-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.scan-progress-card.idle {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.scan-progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.scan-progress-track {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #0d1822;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-progress-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #57b6ff, #72e8c8);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.scan-progress-detail {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 128px);
|
||||
display: grid;
|
||||
@@ -209,6 +256,7 @@ summary {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.toolbar { min-width: 0; }
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
input { min-width: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user