feat: card trafic réseau live par IP (nf_conntrack)
Nouveau card "Trafic réseau par appareil" : lit nf_conntrack en live, groupe par IP locale 192.168.1.x, affiche connexions ESTABLISHED, débit DL/UL en bytes/s (delta entre deux snapshots) et les services actifs. nf_conntrack_acct activé au démarrage pour les compteurs bytes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8d9eaf98c2
commit
0895c15778
+141
@@ -488,4 +488,145 @@ def api_netmap_name(payload: dict = Body(...)):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────── NETCONN ──
|
||||||
|
|
||||||
|
_CONNTRACK = "/proc/net/nf_conntrack"
|
||||||
|
_CONNTRACK_ACCT = "/proc/sys/net/netfilter/nf_conntrack_acct"
|
||||||
|
_conn_prev: dict = {} # key=conn_id → {"b_fwd": int, "b_rev": int, "ts": float}
|
||||||
|
|
||||||
|
# Activer le comptage de bytes à l'initialisation
|
||||||
|
try:
|
||||||
|
with open(_CONNTRACK_ACCT, "w") as _f:
|
||||||
|
_f.write("1")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_LOCAL_NETS = re.compile(r"^(192\.168\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$")
|
||||||
|
_LAN = re.compile(r"^192\.168\.1\.\d+$")
|
||||||
|
|
||||||
|
_PORT_NAMES = {
|
||||||
|
80: "http", 443: "https", 22: "ssh", 21: "ftp", 53: "dns",
|
||||||
|
3306: "mysql", 5432: "pg", 6379: "redis", 1883: "mqtt",
|
||||||
|
32400: "plex", 8096: "jellyfin", 8123: "ha", 3500: "gitea",
|
||||||
|
8000: "homelabdash", 9300: "percohub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_conntrack() -> list[dict]:
|
||||||
|
try:
|
||||||
|
with open(_CONNTRACK) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
conns = []
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split()
|
||||||
|
proto = parts[2] if len(parts) > 2 else ""
|
||||||
|
state = ""
|
||||||
|
for p in parts:
|
||||||
|
if p in ("ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT", "SYN_SENT", "CLOSE"):
|
||||||
|
state = p
|
||||||
|
break
|
||||||
|
|
||||||
|
kv = {}
|
||||||
|
seen_src = seen_dst = False
|
||||||
|
for p in parts:
|
||||||
|
if "=" not in p:
|
||||||
|
continue
|
||||||
|
k, v = p.split("=", 1)
|
||||||
|
if k == "src":
|
||||||
|
kv["src2" if seen_src else "src"] = v
|
||||||
|
seen_src = True
|
||||||
|
elif k == "dst":
|
||||||
|
kv["dst2" if seen_dst else "dst"] = v
|
||||||
|
seen_dst = True
|
||||||
|
elif k in ("sport", "dport", "bytes", "packets"):
|
||||||
|
if k not in kv:
|
||||||
|
kv[k] = int(v)
|
||||||
|
else:
|
||||||
|
kv[k + "2"] = int(v)
|
||||||
|
|
||||||
|
src, dst = kv.get("src", ""), kv.get("dst", "")
|
||||||
|
sport, dport = kv.get("sport", 0), kv.get("dport", 0)
|
||||||
|
b_fwd = kv.get("bytes", 0)
|
||||||
|
b_rev = kv.get("bytes2", 0)
|
||||||
|
|
||||||
|
# Déterminer le sens : LAN→ext ou ext→LAN
|
||||||
|
src_lan = bool(_LAN.match(src))
|
||||||
|
dst_lan = bool(_LAN.match(dst))
|
||||||
|
src2_lan = bool(_LAN.match(kv.get("src2", "")))
|
||||||
|
|
||||||
|
if src_lan and not dst_lan:
|
||||||
|
local_ip, remote_ip, local_port, remote_port = src, dst, sport, dport
|
||||||
|
dl, ul = b_rev, b_fwd
|
||||||
|
elif dst_lan and not src_lan:
|
||||||
|
local_ip, remote_ip, local_port, remote_port = dst, src, dport, sport
|
||||||
|
dl, ul = b_fwd, b_rev
|
||||||
|
elif src_lan and dst_lan:
|
||||||
|
local_ip, remote_ip, local_port, remote_port = src, dst, sport, dport
|
||||||
|
dl, ul = b_rev, b_fwd
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
conns.append({
|
||||||
|
"local_ip": local_ip,
|
||||||
|
"remote_ip": remote_ip,
|
||||||
|
"local_port": local_port,
|
||||||
|
"remote_port": remote_port,
|
||||||
|
"proto": proto,
|
||||||
|
"state": state,
|
||||||
|
"dl_bytes": dl,
|
||||||
|
"ul_bytes": ul,
|
||||||
|
"service": _PORT_NAMES.get(remote_port, _PORT_NAMES.get(local_port, "")),
|
||||||
|
})
|
||||||
|
return conns
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/netconn")
|
||||||
|
def api_netconn():
|
||||||
|
global _conn_prev
|
||||||
|
now = time.time()
|
||||||
|
conns = _parse_conntrack()
|
||||||
|
|
||||||
|
# Regrouper par IP locale
|
||||||
|
by_ip: dict = {}
|
||||||
|
for c in conns:
|
||||||
|
ip = c["local_ip"]
|
||||||
|
if ip not in by_ip:
|
||||||
|
by_ip[ip] = {"ip": ip, "conns": 0, "established": 0,
|
||||||
|
"dl_bytes": 0, "ul_bytes": 0, "top": []}
|
||||||
|
entry = by_ip[ip]
|
||||||
|
entry["conns"] += 1
|
||||||
|
if c["state"] == "ESTABLISHED":
|
||||||
|
entry["established"] += 1
|
||||||
|
entry["dl_bytes"] += c["dl_bytes"]
|
||||||
|
entry["ul_bytes"] += c["ul_bytes"]
|
||||||
|
if c["state"] == "ESTABLISHED" and len(entry["top"]) < 5:
|
||||||
|
entry["top"].append({
|
||||||
|
"remote": c["remote_ip"],
|
||||||
|
"port": c["remote_port"],
|
||||||
|
"service": c["service"],
|
||||||
|
"proto": c["proto"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calcul du débit (delta vs snapshot précédent)
|
||||||
|
dt = now - _conn_prev.get("_ts", now - 1)
|
||||||
|
dt = max(dt, 0.5)
|
||||||
|
result = []
|
||||||
|
for ip, d in sorted(by_ip.items(), key=lambda x: x[1]["dl_bytes"] + x[1]["ul_bytes"], reverse=True):
|
||||||
|
prev = _conn_prev.get(ip, {})
|
||||||
|
dl_prev = prev.get("dl_bytes", d["dl_bytes"])
|
||||||
|
ul_prev = prev.get("ul_bytes", d["ul_bytes"])
|
||||||
|
dl_bps = max(0, (d["dl_bytes"] - dl_prev) / dt)
|
||||||
|
ul_bps = max(0, (d["ul_bytes"] - ul_prev) / dt)
|
||||||
|
result.append({**d, "dl_bps": dl_bps, "ul_bps": ul_bps})
|
||||||
|
|
||||||
|
# Sauvegarder le snapshot
|
||||||
|
_conn_prev = {ip: {"dl_bytes": d["dl_bytes"], "ul_bytes": d["ul_bytes"]} for ip, d in by_ip.items()}
|
||||||
|
_conn_prev["_ts"] = now
|
||||||
|
|
||||||
|
return {"hosts": result, "ts": int(now)}
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
|
|||||||
@@ -103,6 +103,15 @@
|
|||||||
.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); }
|
||||||
|
|
||||||
|
/* NETCONN */
|
||||||
|
.nc-table td { padding: 6px 8px; font-size: 12px; }
|
||||||
|
.nc-ip { font-family: monospace; color: var(--accent); font-weight: 700; font-size: 13px; }
|
||||||
|
.nc-bps { font-weight: 700; }
|
||||||
|
.nc-dl { color: var(--teal); }
|
||||||
|
.nc-ul { color: var(--orange); }
|
||||||
|
.nc-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.nc-chip { background: #21262d; border-radius: 4px; padding: 2px 7px; font-size: 11px; color: var(--muted); font-family: monospace; }
|
||||||
|
|
||||||
/* NETMAP */
|
/* NETMAP */
|
||||||
.netmap-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; }
|
.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 { background: var(--accent); color: #0d1117; border: none; border-radius: 6px; padding: 6px 16px; font-size: 12px; font-weight: 700; cursor: pointer; transition: opacity .15s; }
|
||||||
@@ -380,6 +389,11 @@ function build_ui(os, hw, raid) {
|
|||||||
'<div id="temp-table"></div>'
|
'<div id="temp-table"></div>'
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// NetConn
|
||||||
|
grid.appendChild(make_card('card-netconn','col-12','🔌','Trafic réseau par appareil — live',
|
||||||
|
'<div id="netconn-body"><span style="color:var(--muted)">Chargement…</span></div>'
|
||||||
|
));
|
||||||
|
|
||||||
// NetMap
|
// NetMap
|
||||||
grid.appendChild(make_card('card-netmap','col-12','📡','Appareils réseau — 192.168.1.10–150',
|
grid.appendChild(make_card('card-netmap','col-12','📡','Appareils réseau — 192.168.1.10–150',
|
||||||
`<div class="netmap-toolbar">
|
`<div class="netmap-toolbar">
|
||||||
@@ -503,6 +517,46 @@ function update_live(live) {
|
|||||||
setTimeout(() => { dot.style.background = 'var(--green)'; }, 250);
|
setTimeout(() => { dot.style.background = 'var(--green)'; }, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── netconn ──
|
||||||
|
let _ncNames = {};
|
||||||
|
|
||||||
|
async function ncRefresh() {
|
||||||
|
try {
|
||||||
|
const data = await fetch('/api/netconn').then(r => r.json());
|
||||||
|
ncRender(data.hosts || []);
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ncRender(hosts) {
|
||||||
|
const el = document.getElementById('netconn-body');
|
||||||
|
if (!el) return;
|
||||||
|
if (!hosts.length) { el.innerHTML = '<span style="color:var(--muted)">Aucune connexion LAN active</span>'; return; }
|
||||||
|
const rows = hosts.map(h => {
|
||||||
|
const name = _ncNames[h.ip] || '';
|
||||||
|
const dlBps = fmt_bps(h.dl_bps || 0);
|
||||||
|
const ulBps = fmt_bps(h.ul_bps || 0);
|
||||||
|
const chips = (h.top || []).map(c => {
|
||||||
|
const label = c.service ? `${c.service}:${c.port}` : c.port;
|
||||||
|
return `<span class="nc-chip" title="${c.remote}">${label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const active = h.dl_bps > 1024 || h.ul_bps > 1024;
|
||||||
|
return `<tr${active ? ' style="background:#1a2020"' : ''}>
|
||||||
|
<td class="nc-ip">${h.ip}${name ? ` <span style="color:var(--muted);font-size:11px">${name}</span>` : ''}</td>
|
||||||
|
<td style="text-align:center;color:var(--muted)">${h.established}</td>
|
||||||
|
<td class="nc-bps nc-dl">${dlBps}</td>
|
||||||
|
<td class="nc-bps nc-ul">${ulBps}</td>
|
||||||
|
<td><div class="nc-chips">${chips || '<span style="color:var(--muted);font-size:11px">—</span>'}</div></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
el.innerHTML = `<table class="nc-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Appareil</th><th style="text-align:center">Connexions</th>
|
||||||
|
<th>⬇ Download</th><th>⬆ Upload</th><th>Services actifs</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────── netmap ──
|
// ──────────────────────────────── netmap ──
|
||||||
let _netmapNames = {};
|
let _netmapNames = {};
|
||||||
|
|
||||||
@@ -592,10 +646,17 @@ async function init() {
|
|||||||
build_ui(os, hw, raid);
|
build_ui(os, hw, raid);
|
||||||
const live = await fetch('/api/live').then(r=>r.json());
|
const live = await fetch('/api/live').then(r=>r.json());
|
||||||
update_live(live);
|
update_live(live);
|
||||||
|
// Charger les noms netmap pour les labels d'appareils dans netconn
|
||||||
|
fetch('/api/netmap/names').then(r => r.json()).then(d => { _ncNames = d; });
|
||||||
|
|
||||||
|
// Premier chargement netconn
|
||||||
|
ncRefresh();
|
||||||
|
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const live = await fetch('/api/live').then(r=>r.json());
|
const live = await fetch('/api/live').then(r=>r.json());
|
||||||
update_live(live);
|
update_live(live);
|
||||||
|
ncRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('refresh-dot').style.background = 'var(--red)';
|
document.getElementById('refresh-dot').style.background = 'var(--red)';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user