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 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-06 10:31:21 -04:00
co-authored by Claude Fable 5
parent e652b0f7bd
commit db16964d5a
4 changed files with 47 additions and 33 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dn
### Cockpit dashboard ### Cockpit dashboard
- `https://<wayback>:9090` → "Van Router". Reload the browser after deploying plugin changes (Cockpit caches packages per session). - `https://<wayback>: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). - 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`) ### Home Assistant VM (`ha_van`)
- HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN - HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN
+1 -4
View File
@@ -16,10 +16,7 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-head"> <h3>Access Points</h3>
<h3>Access Point</h3>
<button id="ap-restart" class="btn">Restart AP</button>
</div>
<div id="ap"></div> <div id="ap"></div>
</div> </div>
+1
View File
@@ -19,6 +19,7 @@ th { color: #6a6e73; font-weight: 600; }
.bar-fill.warn { background: #e08a00; } .bar-fill.warn { background: #e08a00; }
.bar-fill.crit { background: #c9190b; } .bar-fill.crit { background: #c9190b; }
.muted { color: #6a6e73; font-size: 12px; } .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; } .active-wan { font-weight: 700; color: #0066cc; }
.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; } .btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
.btn[disabled] { cursor: default; opacity: 0.5; } .btn[disabled] { cursor: default; opacity: 0.5; }
+43 -28
View File
@@ -4,7 +4,11 @@
// Read-only status via cockpit.spawn (logged-in user); mutating actions use // Read-only status via cockpit.spawn (logged-in user); mutating actions use
// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation. // { 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 const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
function run(args, opts) { function run(args, opts) {
@@ -24,19 +28,19 @@ function esc_sh(s) {
/* ---------- Access Point ---------- */ /* ---------- Access Point ---------- */
async function readAP() { async function readAP(a) {
let active = "inactive"; 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 = ""; 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 ssid = (info.match(/\bssid (.+)/) || [])[1];
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0]; const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1]; const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
let stations = []; let stations = [];
try { 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 => ({ stations = dump.split(/Station /).slice(1).map(b => ({
mac: b.split(" ")[0], mac: b.split(" ")[0],
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1], sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
@@ -44,26 +48,36 @@ async function readAP() {
})); }));
} catch (e) { stations = []; } } catch (e) { stations = []; }
return { active, ssid, chan, width, stations }; return { band: a.band, unit: a.unit, active, ssid, chan, width, stations };
} }
function renderAP(ap) { function renderAPs(aps) {
const beaconing = !!ap.ssid; const el = document.getElementById("ap");
let html = `<p>hostapd: <span class="pill ${ap.active === "active" ? "ok" : "bad"}">${esc(ap.active)}</span>`; el.innerHTML = "";
if (beaconing) aps.forEach(ap => {
html += ` &nbsp; SSID <b>${esc(ap.ssid)}</b> &nbsp; ${esc(ap.chan || "")} &nbsp; ${esc(ap.width || "")}`; const beaconing = !!ap.ssid;
else const div = document.createElement("div");
html += ` &nbsp; <span class="pill bad">not beaconing</span>`; div.className = "ap-band";
html += `</p><p>Clients: <b>${ap.stations.length}</b></p>`; let html = `<p><b>${esc(ap.band)}</b> &nbsp; ${esc(ap.unit)}: ` +
`<span class="pill ${ap.active === "active" ? "ok" : "bad"}">${esc(ap.active)}</span>`;
if (beaconing)
html += ` &nbsp; SSID <b>${esc(ap.ssid)}</b> &nbsp; ${esc(ap.chan || "")} &nbsp; ${esc(ap.width || "")}`;
else
html += ` &nbsp; <span class="pill bad">not beaconing</span>`;
html += ` &nbsp; Clients: <b>${ap.stations.length}</b>` +
` <button class="btn ap-restart">Restart</button></p>`;
if (ap.stations.length) { if (ap.stations.length) {
html += `<table><thead><tr><th>MAC</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`; html += `<table><thead><tr><th>MAC</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
ap.stations.forEach(s => { ap.stations.forEach(s => {
html += `<tr><td>${esc(s.mac)}</td><td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`; html += `<tr><td>${esc(s.mac)}</td><td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`;
}); });
html += `</tbody></table>`; html += `</tbody></table>`;
} }
document.getElementById("ap").innerHTML = html; div.innerHTML = html;
div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
el.appendChild(div);
});
} }
/* ---------- Temperatures (van-thermal daemon state) ---------- */ /* ---------- Temperatures (van-thermal daemon state) ---------- */
@@ -175,7 +189,8 @@ async function readWAN() {
const devs = devOut.trim().split("\n").map(line => { const devs = devOut.trim().split("\n").map(line => {
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") && d.device !== AP); }).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
!APS.some(a => a.iface === d.device));
let routes = []; let routes = [];
try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { 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(); refresh();
} }
async function restartAP() { async function restartAP(unit) {
try { await run(["systemctl", "restart", "hostapd"], { superuser: "require" }); } try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
catch (e) { window.alert("Restart failed: " + e.message); } catch (e) { window.alert("Restart failed: " + e.message); }
setTimeout(refresh, 2500); setTimeout(refresh, 2500);
} }
@@ -264,8 +279,9 @@ async function restartAP() {
async function refresh() { async function refresh() {
try { try {
const [ap, th, bat, fo, wan] = await Promise.all([readAP(), readThermal(), readBattery(), readFailover(), readWAN()]); const [aps, th, bat, fo, wan] = await Promise.all([
renderAP(ap); Promise.all(APS.map(readAP)), readThermal(), readBattery(), readFailover(), readWAN()]);
renderAPs(aps);
renderThermal(th); renderThermal(th);
renderBattery(bat); renderBattery(bat);
renderFailover(fo); renderFailover(fo);
@@ -276,6 +292,5 @@ async function refresh() {
} }
} }
document.getElementById("ap-restart").onclick = restartAP;
refresh(); refresh();
setInterval(refresh, 5000); setInterval(refresh, 5000);