Auto-populate network devices via nmap scan from PVE1 every 3min

This commit is contained in:
2026-05-30 03:11:14 +00:00
parent 07827651f5
commit 2faeb5498a
2 changed files with 163 additions and 30 deletions
+56
View File
@@ -311,6 +311,62 @@ function collect_all(): array {
$results['sites'] = 'error: ' . $e->getMessage();
}
// ── Network Device Scan (nmap via PVE1) ───────────────────────────────
try {
$nmapRaw = shell_exec(
"sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 " .
"root@10.48.200.90 'nmap -sn --send-ip 10.48.200.0/24 2>/dev/null' 2>/dev/null"
);
if ($nmapRaw) {
$discovered = [];
$cur = null;
foreach (explode("\n", $nmapRaw) as $line) {
$line = trim($line);
if (preg_match('/Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?/', $line, $m)) {
if ($cur) $discovered[] = $cur;
$cur = ['hostname' => ($m[1] && $m[1] !== $m[2]) ? $m[1] : null, 'ip' => $m[2], 'mac' => null, 'vendor' => null];
} elseif ($cur && preg_match('/MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)/i', $line, $m)) {
$cur['mac'] = strtolower($m[1]);
$cur['vendor'] = $m[2] !== 'Unknown' ? $m[2] : null;
}
}
if ($cur) $discovered[] = $cur;
$discoveredIPs = [];
foreach ($discovered as $d) {
$discoveredIPs[] = $d['ip'];
JarvisDB::execute(
'INSERT INTO network_devices (ip, mac, hostname, status, last_seen)
VALUES (?,?,?,"online",NOW())
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname),
status="online", last_seen=NOW()',
[$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']]
);
if ($d['vendor']) {
JarvisDB::execute(
'UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")',
[$d['vendor'], $d['ip']]
);
}
}
if (!empty($discoveredIPs)) {
$ph = implode(',', array_fill(0, count($discoveredIPs), '?'));
JarvisDB::execute(
"UPDATE network_devices SET status='offline'
WHERE ip NOT IN ($ph) AND last_seen < DATE_SUB(NOW(), INTERVAL 10 MINUTE)",
$discoveredIPs
);
}
$results['nmap_scan'] = 'ok (' . count($discovered) . ' devices found)';
} else {
$results['nmap_scan'] = 'skipped (PVE1 unreachable)';
}
} catch (Exception $e) {
$results['nmap_scan'] = 'error: ' . $e->getMessage();
}
return $results;
}
+107 -30
View File
@@ -10,6 +10,17 @@ function loggedIn(): bool { return !empty($_SESSION['admin_user']); }
function j(mixed $d): never { header('Content-Type: application/json'); echo json_encode($d); exit; }
function bad(string $msg, int $code = 400): never { http_response_code($code); j(['error' => $msg]); }
function self_upsert_device(array $d): void {
JarvisDB::execute(
'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW())
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()',
[$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']]
);
if (!empty($d['vendor'])) {
JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]);
}
}
// ── BACKEND API ───────────────────────────────────────────────────────────────
$action = $_GET['action'] ?? $_POST['action'] ?? '';
if ($action) {
@@ -74,7 +85,26 @@ if ($action) {
// ── NETWORK ──────────────────────────────────────────────────────────
case 'network_list':
j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices WHERE alias IS NOT NULL ORDER BY alias'));
j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)'));
case 'network_scan':
// Trigger immediate nmap scan via PVE1
$out = shell_exec("sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@10.48.200.90 'nmap -sn --send-ip 10.48.200.0/24 2>/dev/null' 2>/dev/null");
$count = 0;
if ($out) {
$cur = null;
foreach (explode("\n", $out) as $line) {
$line = trim($line);
if (preg_match('/Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?/', $line, $m)) {
if ($cur) { self_upsert_device($cur); $count++; }
$cur = ['hostname' => ($m[1] && $m[1] !== $m[2]) ? $m[1] : null, 'ip' => $m[2], 'mac' => null, 'vendor' => null];
} elseif ($cur && preg_match('/MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)/i', $line, $m)) {
$cur['mac'] = strtolower($m[1]); $cur['vendor'] = $m[2] !== 'Unknown' ? $m[2] : null;
}
}
if ($cur) { self_upsert_device($cur); $count++; }
}
j(['ok' => true, 'found' => $count]);
case 'network_save':
$id = (int)($_POST['id'] ?? 0);
@@ -406,9 +436,18 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="page-title">NETWORK DEVICES
<div class="actions">
<button class="btn btn-sm btn-green" onclick="netModal()">+ ADD DEVICE</button>
<button class="btn btn-sm btn-yellow" id="scanBtn" onclick="scanNow()">SCAN NOW</button>
<button class="btn btn-sm" onclick="loadNetwork()">REFRESH</button>
</div>
</div>
<div class="filters" style="margin-bottom:12px">
<span class="lbl">FILTER:</span>
<button class="filter-btn active" id="nf-all" onclick="setNetFilter('all',this)">ALL</button>
<button class="filter-btn" id="nf-online" onclick="setNetFilter('online',this)">ONLINE</button>
<button class="filter-btn" id="nf-offline" onclick="setNetFilter('offline',this)">OFFLINE</button>
<button class="filter-btn" id="nf-named" onclick="setNetFilter('named',this)">NAMED</button>
&nbsp;<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="network-tbl"><div class="loading">LOADING...</div></div>
</div>
@@ -566,10 +605,12 @@ function nav(el) {
}
function loadTab(tab) {
// Stop any existing network auto-refresh when leaving
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
({
dashboard: loadDashboard,
agents: loadAgents,
network: loadNetwork,
network: ()=>{ loadNetwork(); _netAutoRefresh = setInterval(loadNetwork, 30000); },
alerts: loadAlerts,
facts: ()=>{ loadFactCategories(); loadFacts(); },
intents: loadIntents,
@@ -666,29 +707,74 @@ function delAgent(id, name) {
}
// ── NETWORK ───────────────────────────────────────────────────────────────────
let _netFilter = 'all';
let _allDevices = [];
let _netAutoRefresh = null;
function setNetFilter(f, el) {
_netFilter = f;
document.querySelectorAll('#tab-network .filter-btn').forEach(b=>b.classList.remove('active'));
el.classList.add('active');
renderNetwork();
}
async function loadNetwork() {
document.getElementById('network-tbl').innerHTML = '<div class="loading">LOADING...</div>';
const devs = await api('network_list');
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO NAMED DEVICES</div>'; return; }
let rows = devs.map(d => `<tr>
<td><span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>${esc(d.alias)}</td>
<td>${esc(d.ip)}</td>
<td><span class="badge badge-dim">${esc(d.device_type||'device').toUpperCase()}</span></td>
<td>${statusBadge(d.status)}</td>
<td class="ts">${ago(d.last_seen)}</td>
<td><div class="actions-col">
<button class="btn btn-xs" onclick="pingDev(${d.id},'${esc(d.ip)}',this)">PING</button>
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias)}','${esc(d.device_type||'')}')">EDIT</button>
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(d.alias)}')">DEL</button>
</div></td>
</tr>`).join('');
_allDevices = await api('network_list');
renderNetwork();
}
function renderNetwork() {
let devs = _allDevices;
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
if (_netFilter === 'named') devs = devs.filter(d => d.alias);
const onlineCount = _allDevices.filter(d=>d.status==='online').length;
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`;
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO DEVICES MATCH FILTER</div>'; return; }
let rows = devs.map(d => {
const name = d.alias || d.hostname || d.ip;
const isNamed = !!d.alias;
const vendor = d.device_type || '—';
return `<tr>
<td>
<span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
<strong>${esc(name)}</strong>${isNamed?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}
</td>
<td style="color:var(--cyan)">${esc(d.ip)}</td>
<td style="font-size:0.65rem;color:var(--dim)">${esc(d.mac||'—')}</td>
<td class="trunc ts" style="max-width:140px" title="${esc(vendor)}">${esc(vendor)}</td>
<td>${statusBadge(d.status)}</td>
<td class="ts">${ago(d.last_seen)}</td>
<td><div class="actions-col">
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button>
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias||'')}','${esc(d.device_type||'')}')">NAME</button>
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
</div></td>
</tr>`;
}).join('');
document.getElementById('network-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>IP</th><th>TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
<thead><tr><th>NAME</th><th>IP</th><th>MAC</th><th>VENDOR / TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
function netModal(id=0, ip='', alias='', type='device') {
openModal(id?'EDIT DEVICE':'ADD DEVICE', `
async function scanNow() {
const btn = document.getElementById('scanBtn');
btn.textContent = 'SCANNING...'; btn.disabled = true;
const fd = new FormData(); fd.append('action','network_scan');
try {
const r = await fetch(location.href,{method:'POST',body:fd});
const d = await r.json();
if (d.ok) { toast(`Scan complete — ${d.found} devices found`,'ok'); loadNetwork(); }
else toast(d.error||'Scan failed','err');
} catch(e){ toast('Scan failed','err'); }
btn.textContent = 'SCAN NOW'; btn.disabled = false;
}
function netModal(id=0, ip='', alias='', type='') {
openModal(id?'NAME / EDIT DEVICE':'ADD DEVICE', `
<div class="form-row"><label>IP ADDRESS</label><input id="f-ip" value="${esc(ip)}" placeholder="10.48.200.x"></div>
<div class="form-row"><label>NAME / ALIAS</label><input id="f-alias" value="${esc(alias)}" placeholder="My Device"></div>
<div class="form-row"><label>TYPE</label>
@@ -703,16 +789,7 @@ function netModal(id=0, ip='', alias='', type='device') {
});
}
async function pingDev(id, ip, btn) {
btn.textContent = '...'; btn.disabled = true;
const res = await apiPost('network_ping', {ip}, null);
// Re-fetch to update status
loadNetwork();
btn.textContent = 'PING'; btn.disabled = false;
}
// Direct ping returning result
(window.pingDev = async function(id, ip, btn) {
async function pingDev(ip, btn) {
btn.textContent='…'; btn.disabled=true;
const fd=new FormData(); fd.append('action','network_ping'); fd.append('ip',ip);
try {
@@ -721,7 +798,7 @@ async function pingDev(id, ip, btn) {
toast(d.alive ? `${ip} ONLINE (${d.latency_ms??'?'}ms)` : `${ip} OFFLINE`, d.alive?'ok':'err');
} catch(e){ toast('Ping failed','err'); }
btn.textContent='PING'; btn.disabled=false;
});
}
function delNet(id, name) {
if (!confirm(`Remove "${name}" from network devices?`)) return;