starlink: dish route + status card on the Van Router page

The Starlink RTL8153 gets a declarative NM profile in 50-van-wan.yaml
(replaces the auto 'Wired connection 1') with a 192.168.100.1/32 link route,
so the dish's management address stays reachable from the router and — via
the existing !br0 masquerade — from the van LAN, regardless of which WAN
holds the default route.

New Cockpit "Starlink" card queries the dish's gRPC API (:9200, get_status
via grpcurl — not packaged in apt, deploy.sh warns when missing) inside the
existing single batched spawn: online/obstructed/outage pill, alerts,
uptime, sw version, PoP latency, down/up throughput, obstruction %.
Sentinels distinguish adapter-absent / no-grpcurl / dish-unreachable.

Gotcha captured while cutting over: a WAN profile without ipv4.route-metric
makes NM re-assert its DHCP default (metric ~101) against van-failover's
enforce_route pruning every cycle; the profile metric must match (failover's
set_profile_metric does this on health transitions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-12 07:06:43 -04:00
co-authored by Claude Fable 5
parent 2da97d26f4
commit 751facd511
4 changed files with 92 additions and 3 deletions
+69
View File
@@ -12,6 +12,9 @@ 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
// 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" };
function run(args, opts) {
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
@@ -43,6 +46,13 @@ const STATUS_SCRIPT = (() => {
add("neigh", "ip -j neigh show dev 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
// three failure modes apart; anything starting with '{' is dish status JSON.
add("starlink",
`if ! ip link show ${STARLINK.iface} >/dev/null 2>&1; then echo ABSENT; ` +
`elif ! command -v grpcurl >/dev/null 2>&1; then echo NOGRPCURL; ` +
`else timeout 4 grpcurl -plaintext -max-time 3 -d '{"get_status":{}}' ` +
`${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`);
add("battery", "cat /run/van-battery/state.json");
add("failover", "cat /run/van-failover/state.json");
add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status");
@@ -169,6 +179,64 @@ function renderThermal(th) {
el.innerHTML = html;
}
/* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */
function fmtMbps(bps) {
return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps";
}
function fmtUptime(s) {
s = parseInt(s, 10);
if (isNaN(s)) return "—";
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return (d ? `${d}d ` : "") + (d || h ? `${h}h ` : "") + `${m}m`;
}
function renderStarlink(raw) {
const el = document.getElementById("starlink");
const t = (raw || "").trim();
if (!t || t === "ABSENT") {
el.innerHTML = `<p class="muted">Starlink adapter (<code>${esc(STARLINK.iface)}</code>) not plugged in.</p>`;
return;
}
if (t === "NOGRPCURL") {
el.innerHTML = `<p class="muted">grpcurl not installed — the dish status API is gRPC. ` +
`arm64 binary: <code>github.com/fullstorydev/grpcurl/releases</code></p>`;
return;
}
if (t === "UNREACHABLE" || t[0] !== "{") {
el.innerHTML = `<p><span class="pill bad">dish unreachable</span> ` +
`<span class="muted">adapter present but 192.168.100.1 not answering ` +
`(dish booting / unpowered / route missing?)</span></p>`;
return;
}
const st = (parseJSON(t, {}) || {}).dishGetStatus || {};
const obs = st.obstructionStats || {};
const alerts = Object.keys(st.alerts || {}).filter(k => st.alerts[k]);
let state;
if (st.outage)
state = `<span class="pill bad">${esc(st.outage.cause || "OUTAGE")}</span>`;
else if (obs.currentlyObstructed)
state = `<span class="pill warn">obstructed</span>`;
else
state = `<span class="pill ok">online</span>`;
let html = `<p>${state}` +
(alerts.length ? ` <span class="pill warn">alerts: ${esc(alerts.join(", "))}</span>` : "") +
` &nbsp; <span class="muted">uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` +
` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}</span></p>`;
html += `<table><thead><tr><th>Latency (PoP)</th><th>Down</th><th>Up</th><th>Obstructed</th></tr></thead><tbody>`;
html += `<tr><td>${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"}</td>` +
`<td>${esc(fmtMbps(st.downlinkThroughputBps))}</td>` +
`<td>${esc(fmtMbps(st.uplinkThroughputBps))}</td>` +
`<td>${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"}</td></tr>`;
html += `</tbody></table>`;
html += `<p class="muted">Dish web UI: <code>http://192.168.100.1</code> (from the van LAN)</p>`;
el.innerHTML = html;
}
/* ---------- Battery / power source (van-battery daemon state) ---------- */
function renderBattery(b) {
@@ -320,6 +388,7 @@ async function refresh() {
const secs = parseSections(await sh(STATUS_SCRIPT));
renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs));
renderThermal(parseJSON(secs.thermal, null));
renderStarlink(secs.starlink);
renderBattery(parseJSON(secs.battery, null));
renderFailover(parseJSON(secs.failover, null));
renderWAN(parseWAN(secs));