cockpit: unified Clients card (Wi-Fi + wired via bridge FDB)

New "Clients" card lists everything on the van LAN in one table — hostname,
IP, MAC, connection (5GHz / 2.4GHz / LAN eth0 / LAN USB), signal, TX rate.
Wi-Fi rows come from the hostapd station dumps as before; wired rows from
learned bridge-FDB entries on the LAN ports (the port's own MAC is
"permanent", and FDB duplicates entries per vlan — both filtered), enriched
with IP/hostname from dnsmasq leases + neighbor table.

The per-band station tables move out of the Access Points card (which keeps
its status line + client count), and the WAN/Uplinks table now hides
unmanaged devices so the networkd-owned LAN ports don't show up as ghost
WAN rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-12 07:44:57 -04:00
co-authored by Claude Fable 5
parent a36b64c7c4
commit 776f020af3
2 changed files with 58 additions and 12 deletions
+5
View File
@@ -20,6 +20,11 @@
<div id="ap"></div> <div id="ap"></div>
</div> </div>
<div class="card">
<h3>Clients</h3>
<div id="clients"></div>
</div>
<div class="card"> <div class="card">
<h3>Temperatures</h3> <h3>Temperatures</h3>
<div id="thermal"></div> <div id="thermal"></div>
+53 -12
View File
@@ -12,6 +12,8 @@ const APS = [
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU { iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
]; ];
const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
// Wired LAN bridge ports: clients behind them are found via the bridge FDB.
const LAN_PORTS = { "eth0": "LAN (eth0)", "enx00e04c331140": "LAN (USB)" };
// Starlink dish: gRPC status API on the fixed management IP (reached via the /32 // Starlink dish: gRPC status API on the fixed management IP (reached via the /32
// link route the netplan profile installs on the RTL8153 uplink). // link route the netplan profile installs on the RTL8153 uplink).
const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" }; const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" };
@@ -44,6 +46,7 @@ const STATUS_SCRIPT = (() => {
add(`stations${i}`, `iw dev ${a.iface} station dump`); add(`stations${i}`, `iw dev ${a.iface} station dump`);
}); });
add("neigh", "ip -j neigh show dev br0"); add("neigh", "ip -j neigh show dev br0");
add("fdb", "bridge -j fdb show br br0");
add("leases", "cat /var/lib/misc/dnsmasq.leases"); add("leases", "cat /var/lib/misc/dnsmasq.leases");
add("thermal", "cat /run/van-thermal/state.json"); add("thermal", "cat /run/van-thermal/state.json");
// Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the // Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the
@@ -113,7 +116,7 @@ function parseClientDir(secs) {
return dir; return dir;
} }
function renderAPs(aps, dir) { function renderAPs(aps) {
const el = document.getElementById("ap"); const el = document.getElementById("ap");
el.innerHTML = ""; el.innerHTML = "";
aps.forEach(ap => { aps.forEach(ap => {
@@ -128,22 +131,57 @@ function renderAPs(aps, dir) {
html += ` &nbsp; <span class="pill bad">not beaconing</span>`; html += ` &nbsp; <span class="pill bad">not beaconing</span>`;
html += ` &nbsp; Clients: <b>${ap.stations.length}</b>` + html += ` &nbsp; Clients: <b>${ap.stations.length}</b>` +
` <button class="btn ap-restart">Restart</button></p>`; ` <button class="btn ap-restart">Restart</button></p>`;
if (ap.stations.length) {
html += `<table><thead><tr><th>MAC</th><th>IP</th><th>Hostname</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
ap.stations.forEach(s => {
const c = dir[s.mac.toLowerCase()] || {};
html += `<tr><td>${esc(s.mac)}</td><td>${esc(c.ip || "—")}</td><td>${esc(c.host || "—")}</td>` +
`<td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`;
});
html += `</tbody></table>`;
}
div.innerHTML = html; div.innerHTML = html;
div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit); div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
el.appendChild(div); el.appendChild(div);
}); });
} }
/* ---------- Clients (Wi-Fi stations + wired bridge-port FDB) ---------- */
function collectClients(aps, secs, dir) {
// Wi-Fi clients come from the hostapd station dumps (with signal/rate);
// wired ones from learned bridge-FDB entries on the LAN ports.
const rows = [];
const seen = new Set();
aps.forEach(ap => ap.stations.forEach(s => {
const c = dir[s.mac.toLowerCase()] || {};
seen.add(s.mac.toLowerCase());
rows.push({ conn: ap.band, mac: s.mac, ip: c.ip, host: c.host,
sig: s.sig ? s.sig + " dBm" : "?", tx: s.tx || "?" });
}));
parseJSON(secs.fdb, []).forEach(e => {
const conn = LAN_PORTS[e.ifname];
const mac = (e.mac || "").toLowerCase();
// learned entries only: "permanent" = the port's own MAC, and FDB
// duplicates entries per vlan — hence the seen-dedupe.
if (!conn || e.master !== "br0" || e.state === "permanent" || seen.has(mac))
return;
seen.add(mac);
const c = dir[mac] || {};
rows.push({ conn, mac: e.mac, ip: c.ip, host: c.host, sig: "—", tx: "—" });
});
rows.sort((a, b) => a.conn.localeCompare(b.conn) || (a.ip || "").localeCompare(b.ip || ""));
return rows;
}
function renderClients(rows) {
const el = document.getElementById("clients");
if (!rows.length) {
el.innerHTML = `<p class="muted">No clients connected.</p>`;
return;
}
let html = `<table><thead><tr><th>Hostname</th><th>IP</th><th>MAC</th>` +
`<th>Connection</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
rows.forEach(r => {
html += `<tr><td>${esc(r.host || "—")}</td><td>${esc(r.ip || "—")}</td>` +
`<td>${esc(r.mac)}</td><td>${esc(r.conn)}</td>` +
`<td>${esc(r.sig)}</td><td>${esc(r.tx)}</td></tr>`;
});
html += `</tbody></table>`;
el.innerHTML = html;
}
/* ---------- Temperatures (van-thermal daemon state) ---------- */ /* ---------- Temperatures (van-thermal daemon state) ---------- */
function renderThermal(th) { function renderThermal(th) {
@@ -299,6 +337,7 @@ function parseWAN(secs) {
const [device, type, state, ...rest] = line.split(":"); const [device, type, state, ...rest] = line.split(":");
return { device, type, state, connection: rest.join(":") }; return { device, type, state, connection: rest.join(":") };
}).filter(d => (d.type === "ethernet" || d.type === "wifi") && }).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle)
!APS.some(a => a.iface === d.device)); !APS.some(a => a.iface === d.device));
const routes = parseJSON(secs.routes, []); const routes = parseJSON(secs.routes, []);
@@ -386,7 +425,9 @@ async function restartAP(unit) {
async function refresh() { async function refresh() {
try { try {
const secs = parseSections(await sh(STATUS_SCRIPT)); const secs = parseSections(await sh(STATUS_SCRIPT));
renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs)); const aps = APS.map((a, i) => parseAP(a, i, secs));
renderAPs(aps);
renderClients(collectClients(aps, secs, parseClientDir(secs)));
renderThermal(parseJSON(secs.thermal, null)); renderThermal(parseJSON(secs.thermal, null));
renderStarlink(secs.starlink); renderStarlink(secs.starlink);
renderBattery(parseJSON(secs.battery, null)); renderBattery(parseJSON(secs.battery, null));