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:
co-authored by
Claude Sonnet 4.6
parent
c8faffb079
commit
f421636ab8
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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.10–150',
|
||||||
|
`<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 (30–60 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,'"') : ''}" 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([
|
||||||
|
|||||||
Reference in New Issue
Block a user