"use strict"; // Basic Van Router dashboard for Cockpit. // 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) const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN function run(args, opts) { return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {})); } function sh(cmd, opts) { return run(["sh", "-c", cmd], opts); } function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """ }[c])); } // Single-quote a string for safe use inside `sh -c`. function esc_sh(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } /* ---------- Access Point ---------- */ async function readAP() { let active = "inactive"; try { active = (await sh("systemctl is-active hostapd || true")).trim(); } catch (e) { /* ignore */ } let info = ""; try { info = await run(["iw", "dev", AP, "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"]); stations = dump.split(/Station /).slice(1).map(b => ({ mac: b.split(" ")[0], sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1], tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1] })); } catch (e) { stations = []; } return { 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}

`; 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; } /* ---------- Temperatures (van-thermal daemon state) ---------- */ async function readThermal() { try { return JSON.parse(await run(["cat", "/run/van-thermal/state.json"])); } catch (e) { return null; } } function renderThermal(th) { const el = document.getElementById("thermal"); if (!th || !th.sensors) { el.innerHTML = `

van-thermal daemon not running (no state file).

`; return; } const pillClass = { ok: "ok", warn: "warn", crit: "bad" }; let html = ``; Object.keys(th.sensors).forEach(name => { const s = th.sensors[name]; const temp = s.temp == null ? "—" : `${esc(s.temp)} °C`; const lvl = s.level || "ok"; html += `` + `` + ``; }); html += `
SensorTempStatusWarn / Crit
${esc(name.toUpperCase())}${temp}${esc(lvl)}${esc(s.warn)} / ${esc(s.crit)} °C
`; html += `

History: /var/log/van-thermal.csv · alerts: journalctl -u van-thermal

`; el.innerHTML = html; } /* ---------- WAN failover (van-failover daemon state) ---------- */ async function readFailover() { try { return JSON.parse(await run(["cat", "/run/van-failover/state.json"])); } catch (e) { return null; } } function renderFailover(fo) { const el = document.getElementById("failover"); if (!fo || !fo.wans) { el.innerHTML = `

van-failover daemon not running (no state file).

`; return; } let html = `

Active egress: ${esc(fo.active_device || "—")}` + ` · updated ${esc(fo.updated || "")}

`; html += ``; fo.wans.forEach(w => { const status = !w.present ? `absent` : w.up ? `up` : `down`; const prio = w.preferred ? `${esc(w.priority)} (preferred)` : esc(w.priority); html += `` + `` + ``; }); html += `
WANPriorityDeviceStatusMetric
${w.active ? "★ " : ""}${esc(w.name)}${prio}${esc(w.device || "—")}${status}${w.route_metric != null ? esc(w.route_metric) : "—"}
`; el.innerHTML = html; } /* ---------- WAN / uplinks ---------- */ async function readWAN() { const devOut = await run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]); 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); let routes = []; try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; } const metricByDev = {}; routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; }); const activeDev = routes.length ? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev : null; let addrs = []; try { addrs = JSON.parse(await run(["ip", "-j", "-4", "addr"])); } catch (e) { addrs = []; } const ipByDev = {}; addrs.forEach(a => { const info = (a.addr_info || []).find(x => x.family === "inet"); if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen; }); devs.forEach(d => { d.metric = metricByDev[d.device]; d.ip = ipByDev[d.device]; d.active = d.device === activeDev; }); return devs; } function renderWAN(devs) { const tb = document.getElementById("wan"); tb.innerHTML = ""; devs.forEach(d => { const isUp = d.state === "connected"; const tr = document.createElement("tr"); tr.innerHTML = `${d.active ? "★ " : ""}${esc(d.device)}` + `${esc(d.type)}` + `${esc(d.state)}` + `${esc(d.ip || "—")}` + `${d.metric != null ? esc(d.metric) : "—"}` + ``; const acts = tr.querySelector(".acts"); if (d.connection) { const pref = document.createElement("button"); pref.className = "btn"; pref.textContent = "Prefer"; pref.disabled = d.active; pref.onclick = () => preferWAN(d); acts.appendChild(pref); } const tog = document.createElement("button"); tog.className = "btn"; tog.textContent = isUp ? "Down" : "Up"; tog.onclick = () => toggleWAN(d, isUp); acts.appendChild(tog); tb.appendChild(tr); }); } async function preferWAN(chosen) { // Don't touch metrics directly — van-failover owns them and would revert us within // one probe cycle (and `nmcli device reapply` flaps the r8152 USB carrier). Instead // record the preference; the daemon gives this device the lowest base metric. try { await sh(`mkdir -p ${esc_sh(PREFER_FILE.replace(/\/[^/]*$/, ""))} && ` + `printf %s ${esc_sh(chosen.device)} > ${esc_sh(PREFER_FILE)}`, { superuser: "require" }); } catch (e) { window.alert("Prefer failed: " + e.message); } setTimeout(refresh, 4500); // daemon enforces on its next probe loop } async function toggleWAN(d, isUp) { try { const verb = isUp ? "disconnect" : "connect"; await run(["nmcli", "device", verb, d.device], { superuser: "require" }); } catch (e) { window.alert("Toggle failed: " + e.message); } refresh(); } async function restartAP() { try { await run(["systemctl", "restart", "hostapd"], { superuser: "require" }); } catch (e) { window.alert("Restart failed: " + e.message); } setTimeout(refresh, 2500); } /* ---------- loop ---------- */ async function refresh() { try { const [ap, th, fo, wan] = await Promise.all([readAP(), readThermal(), readFailover(), readWAN()]); renderAP(ap); renderThermal(th); renderFailover(fo); renderWAN(wan); document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString(); } catch (e) { document.getElementById("updated").textContent = "error: " + (e.message || e); } } document.getElementById("ap-restart").onclick = restartAP; refresh(); setInterval(refresh, 5000);