feat: intégration NetMap dans homelabdash

Nouveau card "Appareils réseau" : scan nmap 192.168.1.10–150,
affichage IP/MAC/fabricant/hostname, nommage persistant par device.
Routes: /api/netmap/scan, /api/netmap/names, /api/netmap/name (POST).
Stockage dans netmap_devices.json (volume Docker).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-28 11:57:32 +02:00
co-authored by Claude Sonnet 4.6
parent c8faffb079
commit f421636ab8
5 changed files with 174 additions and 1 deletions
+1
View File
@@ -4,6 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
smartmontools \ smartmontools \
dmidecode \ dmidecode \
lsb-release \ lsb-release \
nmap \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+78 -1
View File
@@ -1,8 +1,9 @@
import subprocess, json, time, re, os import subprocess, json, time, re, os
from typing import Optional
import psutil import psutil
import cpuinfo import cpuinfo
from fastapi import FastAPI from fastapi import FastAPI, Body
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app = FastAPI() app = FastAPI()
@@ -400,4 +401,80 @@ def api_raid():
return _parse_mdstat() return _parse_mdstat()
# ──────────────────────────────────────────────────────────── NETMAP ──
NETMAP_DATA = "/app/netmap_devices.json"
NETMAP_RANGE = "192.168.1.10-150"
def _netmap_load() -> dict:
try:
with open(NETMAP_DATA) as f:
return json.load(f)
except Exception:
return {}
def _netmap_save(data: dict):
with open(NETMAP_DATA, "w") as f:
json.dump(data, f, indent=2)
def _netmap_parse(output: str) -> list[dict]:
hosts = []
current: dict = {}
for line in output.splitlines():
m_host = re.search(r"Nmap scan report for (.+)", line)
if m_host:
if current.get("ip"):
hosts.append(current)
raw = m_host.group(1)
ip_m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", raw)
if ip_m:
current = {"ip": ip_m.group(1), "hostname": raw.split("(")[0].strip(), "mac": "", "vendor": ""}
else:
current = {"ip": raw.strip(), "hostname": "", "mac": "", "vendor": ""}
continue
m_mac = re.search(r"MAC Address: ([0-9A-F:]+)\s*(?:\((.+)\))?", line, re.I)
if m_mac:
current["mac"] = m_mac.group(1)
current["vendor"] = m_mac.group(2) or ""
if current.get("ip"):
hosts.append(current)
return hosts
@app.get("/api/netmap/scan")
def api_netmap_scan():
try:
r = subprocess.run(
["nmap", "-sn", "-T4", "--host-timeout", "2s", NETMAP_RANGE],
capture_output=True, text=True, timeout=120
)
hosts = _netmap_parse(r.stdout)
return {"hosts": hosts, "names": _netmap_load()}
except Exception as e:
return {"hosts": [], "names": {}, "error": str(e)}
@app.get("/api/netmap/names")
def api_netmap_names():
return _netmap_load()
@app.post("/api/netmap/name")
def api_netmap_name(payload: dict = Body(...)):
ip = payload.get("ip", "").strip()
name = payload.get("name", "").strip()
if not re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
return {"ok": False, "error": "IP invalide"}
data = _netmap_load()
if name:
data[ip] = name
elif ip in data:
del data[ip]
_netmap_save(data)
return {"ok": True}
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+1
View File
@@ -10,6 +10,7 @@ services:
- /sys:/sys:ro - /sys:/sys:ro
- /dev:/dev - /dev:/dev
- /run/udev:/run/udev:ro - /run/udev:/run/udev:ro
- /home/perco/projects/homelabdash/netmap_devices.json:/app/netmap_devices.json
environment: environment:
- HOST_ROOT=/rootfs - HOST_ROOT=/rootfs
- TZ=Europe/Paris - TZ=Europe/Paris
+1
View File
@@ -0,0 +1 @@
{}
+93
View File
@@ -103,6 +103,18 @@
.s-size { font-size: 18px; font-weight: 800; color: var(--accent); } .s-size { font-size: 18px; font-weight: 800; color: var(--accent); }
.s-info { font-size: 11px; color: var(--muted); } .s-info { font-size: 11px; color: var(--muted); }
/* NETMAP */
.netmap-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; }
.netmap-btn { background: var(--accent); color: #0d1117; border: none; border-radius: 6px; padding: 6px 16px; font-size: 12px; font-weight: 700; cursor: pointer; transition: opacity .15s; }
.netmap-btn:hover { opacity: .8; }
.netmap-btn:disabled { opacity: .4; cursor: not-allowed; }
.netmap-status { font-size: 12px; color: var(--muted); }
.netmap-name-input { background: #21262d; border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 3px 8px; font-size: 12px; width: 130px; transition: border-color .15s; }
.netmap-name-input:focus { outline: none; border-color: var(--accent); }
.netmap-save-btn { background: none; border: 1px solid var(--border); color: var(--accent); border-radius: 5px; padding: 3px 10px; font-size: 11px; cursor: pointer; transition: background .15s; }
.netmap-save-btn:hover { background: #21262d; }
.netmap-flash { font-size: 11px; color: var(--green); width: 14px; display: inline-block; }
/* RAID */ /* RAID */
.raid-grid { display: grid; gap: 10px; } .raid-grid { display: grid; gap: 10px; }
.raid-array { background: #21262d; border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; } .raid-array { background: #21262d; border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
@@ -368,6 +380,15 @@ function build_ui(os, hw, raid) {
'<div id="temp-table"></div>' '<div id="temp-table"></div>'
)); ));
// NetMap
grid.appendChild(make_card('card-netmap','col-12','📡','Appareils réseau — 192.168.1.10150',
`<div class="netmap-toolbar">
<button class="netmap-btn" id="netmap-scan-btn" onclick="netmapScan()">🔍 Scanner</button>
<span class="netmap-status" id="netmap-status">Cliquez sur Scanner pour démarrer</span>
</div>
<div id="netmap-table"></div>`
));
// Restaurer l'ordre mémorisé // Restaurer l'ordre mémorisé
apply_order(); apply_order();
@@ -482,6 +503,78 @@ function update_live(live) {
setTimeout(() => { dot.style.background = 'var(--green)'; }, 250); setTimeout(() => { dot.style.background = 'var(--green)'; }, 250);
} }
// ──────────────────────────────── netmap ──
let _netmapNames = {};
async function netmapScan() {
const btn = document.getElementById('netmap-scan-btn');
const status = document.getElementById('netmap-status');
if (!btn) return;
btn.disabled = true;
status.style.color = 'var(--yellow)';
status.textContent = '⏳ Scan en cours (3060 s)…';
try {
const data = await fetch('/api/netmap/scan').then(r => r.json());
if (data.error) {
status.style.color = 'var(--red)';
status.textContent = '❌ ' + data.error;
} else {
status.style.color = 'var(--green)';
status.textContent = `${data.hosts.length} appareil(s) détecté(s)`;
_netmapNames = data.names || {};
netmapRender(data.hosts);
}
} catch(e) {
status.style.color = 'var(--red)';
status.textContent = '❌ Erreur : ' + e;
}
btn.disabled = false;
}
function netmapRender(hosts) {
const el = document.getElementById('netmap-table');
if (!el) return;
if (!hosts.length) { el.innerHTML = '<span style="color:var(--muted)">Aucun appareil détecté.</span>'; return; }
const rows = hosts.map(h => {
const k = h.ip.replace(/\./g, '_');
const name = _netmapNames[h.ip] || '';
return `<tr>
<td><code style="color:var(--accent)">${h.ip}</code></td>
<td><code style="color:var(--muted);font-size:12px">${h.mac || '—'}</code></td>
<td style="color:var(--muted);font-size:12px">${h.vendor || '—'}</td>
<td style="color:var(--muted);font-size:12px">${h.hostname || '—'}</td>
<td>
<input class="netmap-name-input" id="nm_${k}" value="${h.ip in _netmapNames ? _netmapNames[h.ip].replace(/"/g,'&quot;') : ''}" placeholder="Nom perso…">
<button class="netmap-save-btn" onclick="netmapSaveName('${h.ip}')">Sauver</button>
<span class="netmap-flash" id="nmf_${k}"></span>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table>
<thead><tr><th>IP</th><th>MAC</th><th>Fabricant</th><th>Hostname</th><th>Nom perso</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
async function netmapSaveName(ip) {
const k = ip.replace(/\./g, '_');
const input = document.getElementById('nm_' + k);
const flash = document.getElementById('nmf_' + k);
if (!input) return;
const name = input.value.trim();
try {
const r = await fetch('/api/netmap/name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ip, name})
});
const d = await r.json();
if (d.ok) { _netmapNames[ip] = name; flash.textContent = '✓'; }
else flash.textContent = '✗';
} catch { flash.textContent = '✗'; }
setTimeout(() => { if (flash) flash.textContent = ''; }, 2000);
}
// ──────────────────────────────── init ── // ──────────────────────────────── init ──
async function init() { async function init() {
const [os, hw, raid] = await Promise.all([ const [os, hw, raid] = await Promise.all([