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 @@
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 += `
| MAC | IP | Hostname | Signal | TX rate |
`;
- ap.stations.forEach(s => {
- const c = dir[s.mac.toLowerCase()] || {};
- html += `| ${esc(s.mac)} | ${esc(c.ip || "—")} | ${esc(c.host || "—")} | ` +
- `${esc(s.sig || "?")} dBm | ${esc(s.tx || "?")} |
`;
- });
- html += `
`;
- }
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 = `
| Hostname | IP | MAC | ` +
+ `Connection | Signal | TX rate |
`;
+ rows.forEach(r => {
+ html += `| ${esc(r.host || "—")} | ${esc(r.ip || "—")} | ` +
+ `${esc(r.mac)} | ${esc(r.conn)} | ` +
+ `${esc(r.sig)} | ${esc(r.tx)} |
`;
+ });
+ html += `
`;
+ 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));