deploy: hardware-instance templating, wlan0 boot watchdog, deploy-time warnings in Cockpit
- deploy.conf templates interface names/USB IDs (@TOKEN@ substitution) across ap/* configs so a dongle swap only needs deploy.conf edited, not the repo configs themselves; drops ap/rtw88.conf (old 2.4GHz dongle retired for the DWA-171, which needs no such power-save override). - failover/van-wlan-watchdog: recovers wlan0 from NM's post-boot no-secrets wedge (a boot-time supplicant race, not a real credential failure). - deploy.sh: warn() collects dependency/config warnings (missing python3-gps, python3-paho-mqtt, mobile-broadband-provider-info, grpcurl, gpsd; netplan drift; unedited example configs) into /var/lib/vanlink/deploy-warnings.json, rendered as an amber Cockpit card so they're visible without reading deploy output. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8d5415f326
commit
5d88e1b30c
@@ -15,6 +15,11 @@
|
||||
<span id="updated" class="muted">loading…</span>
|
||||
</div>
|
||||
|
||||
<div class="card" id="deploywarn-card" style="display:none">
|
||||
<h3>Deploy Warnings</h3>
|
||||
<div id="deploywarn"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Access Points</h3>
|
||||
<div id="ap"></div>
|
||||
@@ -45,6 +50,14 @@
|
||||
<div id="starlink"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h3>Wi-Fi Networks</h3>
|
||||
<button id="wifi-scan" class="btn">Scan</button>
|
||||
</div>
|
||||
<div id="wifi"><p class="muted">Click Scan to search for networks.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>WAN / Uplinks</h3>
|
||||
<table>
|
||||
|
||||
@@ -23,3 +23,7 @@ th { color: #6a6e73; font-weight: 600; }
|
||||
.active-wan { font-weight: 700; color: #0066cc; }
|
||||
.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
|
||||
.btn[disabled] { cursor: default; opacity: 0.5; }
|
||||
#deploywarn-card { border-color: #e08a00; }
|
||||
.warn-list { margin: 0; padding-left: 20px; }
|
||||
.warn-list li { background: #f5d9a8; color: #5f4414; border-radius: 4px;
|
||||
padding: 4px 8px; margin: 4px 0; list-style: none; margin-left: -20px; }
|
||||
|
||||
+141
-12
@@ -7,18 +7,20 @@
|
||||
// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation.
|
||||
|
||||
// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit.
|
||||
// Interface names/vendor ID below are templated from deploy.conf at deploy
|
||||
// time (see deploy.sh's render()) — edit deploy.conf, not the values here.
|
||||
const APS = [
|
||||
{ iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU
|
||||
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
|
||||
{ iface: "@WIFI_5G_IFACE@", unit: "hostapd", band: "5GHz" },
|
||||
{ iface: "@WIFI_2G_IFACE@", unit: "hostapd-2g", band: "2.4GHz" },
|
||||
];
|
||||
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)" };
|
||||
const LAN_PORTS = { "eth0": "LAN (eth0)", "@LAN_USB_IFACE@": "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" };
|
||||
const STARLINK = { iface: "@STARLINK_IFACE@", dish: "192.168.100.1:9200" };
|
||||
// Cellular modem (Quectel EC25-AF): used to find the USB device for a hard restart.
|
||||
const MODEM_USB_VENDOR = "2c7c";
|
||||
const MODEM_USB_VENDOR = "@MODEM_USB_VENDOR@";
|
||||
|
||||
function run(args, opts) {
|
||||
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
|
||||
@@ -47,6 +49,7 @@ const STATUS_SCRIPT = (() => {
|
||||
add(`info${i}`, `iw dev ${a.iface} info`);
|
||||
add(`stations${i}`, `iw dev ${a.iface} station dump`);
|
||||
});
|
||||
add("deploywarn", "cat /var/lib/vanlink/deploy-warnings.json");
|
||||
add("neigh", "ip -j neigh show dev br0");
|
||||
add("fdb", "bridge -j fdb show br br0");
|
||||
add("leases", "cat /var/lib/misc/dnsmasq.leases");
|
||||
@@ -83,6 +86,21 @@ function parseJSON(text, fallback) {
|
||||
try { return JSON.parse(text); } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
/* ---------- Deploy warnings (deploy.sh's collected warn() calls) ---------- */
|
||||
|
||||
function renderDeployWarnings(dw) {
|
||||
const card = document.getElementById("deploywarn-card");
|
||||
const warnings = (dw && dw.warnings) || [];
|
||||
if (!warnings.length) { card.style.display = "none"; return; }
|
||||
card.style.display = "";
|
||||
let html = `<ul class="warn-list">`;
|
||||
warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
|
||||
html += `</ul>`;
|
||||
html += `<p class="muted">From last deploy (${esc(dw.deployed || "—")}) — ` +
|
||||
`re-run <code>sudo ./deploy.sh</code> after fixing to clear.</p>`;
|
||||
document.getElementById("deploywarn").innerHTML = html;
|
||||
}
|
||||
|
||||
/* ---------- Access Point ---------- */
|
||||
|
||||
function parseAP(a, i, secs) {
|
||||
@@ -321,7 +339,7 @@ function renderFailover(fo) {
|
||||
` <span class="muted">· updated ${esc(fo.updated || "")}</span></p>`;
|
||||
html += `<table><thead><tr><th>WAN</th><th>Priority</th><th>Device</th><th>Status</th><th>Metric</th></tr></thead><tbody>`;
|
||||
fo.wans.forEach(w => {
|
||||
const status = !w.present ? `<span class="pill">absent</span>`
|
||||
const status = !w.present ? `<span class="pill warn">absent</span>`
|
||||
: w.up ? `<span class="pill ok">up</span>`
|
||||
: `<span class="pill bad">down</span>`;
|
||||
const prio = w.preferred ? `${esc(w.priority)} <span class="muted">(preferred)</span>` : esc(w.priority);
|
||||
@@ -333,6 +351,106 @@ function renderFailover(fo) {
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ---------- Wi-Fi network selector (wlan0 WAN) ---------- */
|
||||
|
||||
// The onboard radio used for the WiFi WAN — unlike the AP dongles it's never
|
||||
// templated, this host only ever has the one.
|
||||
const WIFI_IFACE = "wlan0";
|
||||
|
||||
// SSIDs the van's own AP radios are currently beaconing (kept live from refresh()'s
|
||||
// `iw dev ... info` parse, not hardcoded) — wlan0 "connecting" to its own AP would be
|
||||
// a nonsensical loop, so those rows get their Connect button disabled below.
|
||||
let ownAPSSIDs = new Set();
|
||||
|
||||
// nmcli -t escapes ':' inside field values as '\:' — split on unescaped ':' only.
|
||||
// SSIDs are environment-controlled strings (the van parks near arbitrary APs), so
|
||||
// worth handling properly rather than a naive split(":").
|
||||
function splitNmcli(line) {
|
||||
const parts = [];
|
||||
let cur = "";
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === "\\" && line[i + 1] === ":") { cur += ":"; i++; }
|
||||
else if (line[i] === ":") { parts.push(cur); cur = ""; }
|
||||
else cur += line[i];
|
||||
}
|
||||
parts.push(cur);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderWifiList(rows) {
|
||||
const el = document.getElementById("wifi");
|
||||
if (!rows.length) {
|
||||
el.innerHTML = `<p class="muted">No networks found. Try Scan.</p>`;
|
||||
return;
|
||||
}
|
||||
let html = `<table><thead><tr><th>SSID</th><th>Signal</th><th>Security</th><th></th></tr></thead><tbody>`;
|
||||
rows.forEach(r => {
|
||||
const ownAP = ownAPSSIDs.has(r.ssid);
|
||||
const label = r.inUse ? "Connected" : ownAP ? "Own AP" : "Connect";
|
||||
html += `<tr><td>${r.inUse ? "★ " : ""}${esc(r.ssid)}</td>` +
|
||||
`<td>${esc(r.signal)}%</td><td>${esc(r.security || "open")}</td>` +
|
||||
`<td class="acts"><button class="btn wifi-connect"${ownAP ? ` title="This is the van's own VanLink AP — wlan0 can't usefully connect to it"` : ""}>${label}</button></td></tr>`;
|
||||
});
|
||||
html += `</tbody></table>`;
|
||||
el.innerHTML = html;
|
||||
el.querySelectorAll(".wifi-connect").forEach((btn, i) => {
|
||||
const r = rows[i];
|
||||
btn.disabled = r.inUse || ownAPSSIDs.has(r.ssid);
|
||||
btn.onclick = () => connectWifi(r);
|
||||
});
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
const el = document.getElementById("wifi");
|
||||
el.innerHTML = `<p class="muted">Scanning…</p>`;
|
||||
try {
|
||||
// Actually triggering a rescan (not just listing NM's cache) needs the
|
||||
// org.freedesktop.NetworkManager.wifi.scan polkit action, which a plain
|
||||
// Cockpit session doesn't have — without escalation this silently returns
|
||||
// only the cached/connected AP instead of erroring.
|
||||
const out = await run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY",
|
||||
"device", "wifi", "list", "ifname", WIFI_IFACE, "--rescan", "yes"],
|
||||
{ superuser: "require" });
|
||||
const seen = new Set();
|
||||
const rows = out.trim().split("\n").filter(Boolean).map(splitNmcli).map(f => ({
|
||||
inUse: f[0].indexOf("*") !== -1, ssid: f[1], signal: f[2], security: f[3],
|
||||
})).filter(r => {
|
||||
if (!r.ssid || seen.has(r.ssid)) return false;
|
||||
seen.add(r.ssid);
|
||||
return true;
|
||||
}).sort((a, b) => (b.inUse - a.inUse) || (parseInt(b.signal, 10) - parseInt(a.signal, 10)));
|
||||
renderWifiList(rows);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p class="muted">Scan failed: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWifi(r) {
|
||||
if (ownAPSSIDs.has(r.ssid)) return; // belt-and-suspenders; button is disabled too
|
||||
// Try without a password first — reuses a saved profile's stored secret (or
|
||||
// just works for an open network); only prompt if NM actually needs one.
|
||||
try {
|
||||
await run(["nmcli", "device", "wifi", "connect", r.ssid, "ifname", WIFI_IFACE],
|
||||
{ superuser: "require" });
|
||||
scanWifi();
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!/secret|password|psk/i.test(e.message || "")) {
|
||||
window.alert("Connect failed: " + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pwd = window.prompt(`Password for "${r.ssid}":`);
|
||||
if (!pwd) return;
|
||||
try {
|
||||
await run(["nmcli", "device", "wifi", "connect", r.ssid, "password", pwd, "ifname", WIFI_IFACE],
|
||||
{ superuser: "require" });
|
||||
} catch (e) { window.alert("Connect failed: " + e.message); }
|
||||
scanWifi();
|
||||
}
|
||||
|
||||
document.getElementById("wifi-scan").onclick = scanWifi;
|
||||
|
||||
/* ---------- WAN / uplinks ---------- */
|
||||
|
||||
// mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem).
|
||||
@@ -402,16 +520,24 @@ function parseWAN(secs) {
|
||||
return devs;
|
||||
}
|
||||
|
||||
// NM device states that count as "ready to serve traffic" vs. transient vs.
|
||||
// broken/unusable — drives the state-column pill color.
|
||||
const WAN_STATE_PILL = { connected: "ok", connecting: "warn" };
|
||||
|
||||
function renderWAN(devs) {
|
||||
const tb = document.getElementById("wan");
|
||||
tb.innerHTML = "";
|
||||
devs.forEach(d => {
|
||||
const isUp = d.state === "connected";
|
||||
const isConnected = d.state === "connected";
|
||||
// No carrier: nmcli can't connect a device with nothing on the other
|
||||
// end of the wire, so the button would just fail — disable it rather
|
||||
// than let it produce a silent "Toggle failed" alert.
|
||||
const noCarrier = d.state === "unavailable";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</td>` +
|
||||
`<td>${esc(d.type)}</td>` +
|
||||
`<td>${esc(d.state)}</td>` +
|
||||
`<td><span class="pill ${WAN_STATE_PILL[d.state] || "bad"}">${esc(d.state)}</span></td>` +
|
||||
`<td>${esc(d.ip || "—")}</td>` +
|
||||
`<td>${d.signal || "—"}</td>` +
|
||||
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
|
||||
@@ -428,8 +554,9 @@ function renderWAN(devs) {
|
||||
}
|
||||
const tog = document.createElement("button");
|
||||
tog.className = "btn";
|
||||
tog.textContent = isUp ? "Down" : "Up";
|
||||
tog.onclick = () => toggleWAN(d, isUp);
|
||||
tog.textContent = isConnected ? "Disconnect" : "Connect";
|
||||
tog.disabled = !isConnected && noCarrier;
|
||||
tog.onclick = () => toggleWAN(d, isConnected);
|
||||
acts.appendChild(tog);
|
||||
|
||||
if (d.type === "gsm") {
|
||||
@@ -456,9 +583,9 @@ async function preferWAN(chosen) {
|
||||
setTimeout(refresh, 4500); // daemon enforces on its next probe loop
|
||||
}
|
||||
|
||||
async function toggleWAN(d, isUp) {
|
||||
async function toggleWAN(d, isConnected) {
|
||||
try {
|
||||
const verb = isUp ? "disconnect" : "connect";
|
||||
const verb = isConnected ? "disconnect" : "connect";
|
||||
await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" });
|
||||
} catch (e) { window.alert("Toggle failed: " + e.message); }
|
||||
refresh();
|
||||
@@ -492,7 +619,9 @@ async function restartAP(unit) {
|
||||
async function refresh() {
|
||||
try {
|
||||
const secs = parseSections(await sh(STATUS_SCRIPT));
|
||||
renderDeployWarnings(parseJSON(secs.deploywarn, null));
|
||||
const aps = APS.map((a, i) => parseAP(a, i, secs));
|
||||
ownAPSSIDs = new Set(aps.map(a => a.ssid).filter(Boolean));
|
||||
renderAPs(aps);
|
||||
renderClients(collectClients(aps, secs, parseClientDir(secs)));
|
||||
renderThermal(parseJSON(secs.thermal, null));
|
||||
|
||||
Reference in New Issue
Block a user