diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html index a87aae0..a9f10d1 100644 --- a/cockpit/vanrouter/index.html +++ b/cockpit/vanrouter/index.html @@ -20,6 +20,11 @@
+
+

Clients

+
+
+

Temperatures

diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js index 6cd5122..9d41d7c 100644 --- a/cockpit/vanrouter/vanrouter.js +++ b/cockpit/vanrouter/vanrouter.js @@ -12,6 +12,8 @@ const APS = [ { 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 +// 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 // link route the netplan profile installs on the RTL8153 uplink). 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("neigh", "ip -j neigh show dev br0"); + add("fdb", "bridge -j fdb show br br0"); add("leases", "cat /var/lib/misc/dnsmasq.leases"); add("thermal", "cat /run/van-thermal/state.json"); // Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the @@ -113,7 +116,7 @@ function parseClientDir(secs) { return dir; } -function renderAPs(aps, dir) { +function renderAPs(aps) { const el = document.getElementById("ap"); el.innerHTML = ""; aps.forEach(ap => { @@ -128,22 +131,57 @@ function renderAPs(aps, dir) { html += `   not beaconing`; html += `   Clients: ${ap.stations.length}` + `

`; - - if (ap.stations.length) { - html += ``; - ap.stations.forEach(s => { - const c = dir[s.mac.toLowerCase()] || {}; - html += `` + - ``; - }); - html += `
MACIPHostnameSignalTX rate
${esc(s.mac)}${esc(c.ip || "—")}${esc(c.host || "—")}${esc(s.sig || "?")} dBm${esc(s.tx || "?")}
`; - } div.innerHTML = html; div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit); 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 = `

No clients connected.

`; + return; + } + let html = `` + + ``; + rows.forEach(r => { + html += `` + + `` + + ``; + }); + html += `
HostnameIPMACConnectionSignalTX rate
${esc(r.host || "—")}${esc(r.ip || "—")}${esc(r.mac)}${esc(r.conn)}${esc(r.sig)}${esc(r.tx)}
`; + el.innerHTML = html; +} + /* ---------- Temperatures (van-thermal daemon state) ---------- */ function renderThermal(th) { @@ -299,6 +337,7 @@ function parseWAN(secs) { const [device, type, state, ...rest] = line.split(":"); return { device, type, state, connection: rest.join(":") }; }).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)); const routes = parseJSON(secs.routes, []); @@ -386,7 +425,9 @@ async function restartAP(unit) { async function refresh() { try { 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)); renderStarlink(secs.starlink); renderBattery(parseJSON(secs.battery, null));