From db16964d5a4e3f4aedd9fdb4398d1db931c03091 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 6 Jul 2026 10:31:21 -0400 Subject: [PATCH] cockpit: show both AP bands with per-band restart vanrouter.js hardcoded the 5GHz iface; generalize to an APS list (iface + hostapd unit + band label). The Access Points card now renders a status line, client table, and Restart button per band, and the WAN table excludes both AP ifaces. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- cockpit/vanrouter/index.html | 5 +-- cockpit/vanrouter/vanrouter.css | 1 + cockpit/vanrouter/vanrouter.js | 71 ++++++++++++++++++++------------- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index ff97e8f..a053d55 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dn ### Cockpit dashboard - `https://:9090` → "Van Router". Reload the browser after deploying plugin changes (Cockpit caches packages per session). - Read-only status works as any user; Prefer/Up/Down/Restart need *Administrative access* (polkit). -- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device; **Restart** restarts hostapd. +- The Access Points card shows both bands (5GHz `hostapd`, 2.4GHz `hostapd-2g`) with per-band client lists and **Restart** buttons; the radio list lives in `vanrouter.js` (`const APS`). +- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device. ### Home Assistant VM (`ha_van`) - HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html index 5f14918..b1bb014 100644 --- a/cockpit/vanrouter/index.html +++ b/cockpit/vanrouter/index.html @@ -16,10 +16,7 @@
-
-

Access Point

- -
+

Access Points

diff --git a/cockpit/vanrouter/vanrouter.css b/cockpit/vanrouter/vanrouter.css index 0143e9c..0d30f91 100644 --- a/cockpit/vanrouter/vanrouter.css +++ b/cockpit/vanrouter/vanrouter.css @@ -19,6 +19,7 @@ th { color: #6a6e73; font-weight: 600; } .bar-fill.warn { background: #e08a00; } .bar-fill.crit { background: #c9190b; } .muted { color: #6a6e73; font-size: 12px; } +.ap-band + .ap-band { border-top: 1px solid #ededed; margin-top: 10px; padding-top: 4px; } .active-wan { font-weight: 700; color: #0066cc; } .btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; } .btn[disabled] { cursor: default; opacity: 0.5; } diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js index 35bebb5..02de422 100644 --- a/cockpit/vanrouter/vanrouter.js +++ b/cockpit/vanrouter/vanrouter.js @@ -4,7 +4,11 @@ // Read-only status via cockpit.spawn (logged-in user); mutating actions use // { superuser: "require" } which triggers Cockpit's admin (polkit) escalation. -const AP = "wlxc83a35a4ee55"; // Realtek RTL8852BU AP interface (MAC-derived, stable) +// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit. +const APS = [ + { iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU + { 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 function run(args, opts) { @@ -24,19 +28,19 @@ function esc_sh(s) { /* ---------- Access Point ---------- */ -async function readAP() { +async function readAP(a) { let active = "inactive"; - try { active = (await sh("systemctl is-active hostapd || true")).trim(); } catch (e) { /* ignore */ } + try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ } let info = ""; - try { info = await run(["iw", "dev", AP, "info"]); } catch (e) { info = ""; } + try { info = await run(["iw", "dev", a.iface, "info"]); } catch (e) { info = ""; } const ssid = (info.match(/\bssid (.+)/) || [])[1]; const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0]; const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1]; let stations = []; try { - const dump = await run(["iw", "dev", AP, "station", "dump"]); + const dump = await run(["iw", "dev", a.iface, "station", "dump"]); stations = dump.split(/Station /).slice(1).map(b => ({ mac: b.split(" ")[0], sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1], @@ -44,26 +48,36 @@ async function readAP() { })); } catch (e) { stations = []; } - return { active, ssid, chan, width, stations }; + return { band: a.band, unit: a.unit, active, ssid, chan, width, stations }; } -function renderAP(ap) { - const beaconing = !!ap.ssid; - let html = `

hostapd: ${esc(ap.active)}`; - if (beaconing) - html += `   SSID ${esc(ap.ssid)}   ${esc(ap.chan || "")}   ${esc(ap.width || "")}`; - else - html += `   not beaconing`; - html += `

Clients: ${ap.stations.length}

`; +function renderAPs(aps) { + const el = document.getElementById("ap"); + el.innerHTML = ""; + aps.forEach(ap => { + const beaconing = !!ap.ssid; + const div = document.createElement("div"); + div.className = "ap-band"; + let html = `

${esc(ap.band)}   ${esc(ap.unit)}: ` + + `${esc(ap.active)}`; + if (beaconing) + html += `   SSID ${esc(ap.ssid)}   ${esc(ap.chan || "")}   ${esc(ap.width || "")}`; + else + html += `   not beaconing`; + html += `   Clients: ${ap.stations.length}` + + `

`; - if (ap.stations.length) { - html += ``; - ap.stations.forEach(s => { - html += ``; - }); - html += `
MACSignalTX rate
${esc(s.mac)}${esc(s.sig || "?")} dBm${esc(s.tx || "?")}
`; - } - document.getElementById("ap").innerHTML = html; + if (ap.stations.length) { + html += ``; + ap.stations.forEach(s => { + html += ``; + }); + html += `
MACSignalTX rate
${esc(s.mac)}${esc(s.sig || "?")} dBm${esc(s.tx || "?")}
`; + } + div.innerHTML = html; + div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit); + el.appendChild(div); + }); } /* ---------- Temperatures (van-thermal daemon state) ---------- */ @@ -175,7 +189,8 @@ async function readWAN() { const devs = devOut.trim().split("\n").map(line => { const [device, type, state, ...rest] = line.split(":"); return { device, type, state, connection: rest.join(":") }; - }).filter(d => (d.type === "ethernet" || d.type === "wifi") && d.device !== AP); + }).filter(d => (d.type === "ethernet" || d.type === "wifi") && + !APS.some(a => a.iface === d.device)); let routes = []; try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; } @@ -254,8 +269,8 @@ async function toggleWAN(d, isUp) { refresh(); } -async function restartAP() { - try { await run(["systemctl", "restart", "hostapd"], { superuser: "require" }); } +async function restartAP(unit) { + try { await run(["systemctl", "restart", unit], { superuser: "require" }); } catch (e) { window.alert("Restart failed: " + e.message); } setTimeout(refresh, 2500); } @@ -264,8 +279,9 @@ async function restartAP() { async function refresh() { try { - const [ap, th, bat, fo, wan] = await Promise.all([readAP(), readThermal(), readBattery(), readFailover(), readWAN()]); - renderAP(ap); + const [aps, th, bat, fo, wan] = await Promise.all([ + Promise.all(APS.map(readAP)), readThermal(), readBattery(), readFailover(), readWAN()]); + renderAPs(aps); renderThermal(th); renderBattery(bat); renderFailover(fo); @@ -276,6 +292,5 @@ async function refresh() { } } -document.getElementById("ap-restart").onclick = restartAP; refresh(); setInterval(refresh, 5000);