noVNC bridge: per-device live screens in dashboard (websockify + TokenFile + VNC button)

This commit is contained in:
drjones
2026-08-13 18:06:14 -07:00
parent 90921f8d9b
commit 2a2d72f288
3 changed files with 63 additions and 1 deletions

12
android-vnc.service Normal file
View File

@@ -0,0 +1,12 @@
[Unit]
Description=Android Fleet — noVNC bridge (per-device live screens)
After=network.target
[Service]
Type=simple
ExecStart=/bin/websockify --web=/usr/share/novnc --token-plugin=TokenFile --token-source=/opt/android-fleet/vnc_tokens.json 0.0.0.0:8081
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

41
app.py
View File

@@ -691,6 +691,45 @@ def api_shot_latest(mac):
return jsonify({"error": "no screenshot yet"}), 404
return send_file(path, mimetype="image/png")
VNC_TOKEN_FILE = "/opt/android-fleet/vnc_tokens.json"
def regen_vnc_tokens():
"""Map each online device MAC -> its QEMU VNC port for websockify TokenFile."""
try:
con = db()
rows = con.execute("SELECT mac, vmid FROM devices WHERE vmid IS NOT NULL").fetchall()
con.close()
lines = []
for r in rows:
got = pve("GET", f"/nodes/pve/qemu/{r['vmid']}/config") or {}
cfg = (got.get("data") or {}) or {}
args = cfg.get("args", "") or ""
m = re.search(r"-vnc 0\.0\.0\.0:(\d{4})", args)
if m:
port = int(m.group(1)) + 5900 # QEMU: -vnc :N listens on N+5900
lines.append(f"{r['mac']}: 10.30.20.85:{port}")
with open(VNC_TOKEN_FILE, "w") as f:
f.write("\n".join(lines) + ("\n" if lines else ""))
except Exception as e:
log("WARN", f"vnc token regen: {e}")
def vnc_loop():
while True:
regen_vnc_tokens()
time.sleep(60)
@app.route("/api/device/<mac>/vnc")
def api_vnc(mac):
regen_vnc_tokens()
try:
tokens = open(VNC_TOKEN_FILE).read()
except FileNotFoundError:
tokens = ""
if mac not in tokens:
return jsonify({"error": "device has no VNC mapping (needs vmid + -vnc args)"}), 404
return jsonify({"url": (f"http://10.30.20.31:8081/vnc.html?autoconnect=true"
f"&reconnect=true&resize=scale&path=websockify?token={mac}")})
@app.route("/api/device/<mac>", methods=["DELETE"])
def api_delete_device(mac):
"""Remove a device row (and release its proxy)."""
@@ -841,7 +880,7 @@ if __name__ == "__main__":
subprocess.run(["adb", "start-server"], capture_output=True, timeout=10)
except Exception:
pass
for fn in (discovery_loop, adb_loop, proxy_loop, ws_broadcast_loop, screenshot_loop):
for fn in (discovery_loop, adb_loop, proxy_loop, ws_broadcast_loop, screenshot_loop, vnc_loop):
threading.Thread(target=fn, daemon=True).start()
log("STARTUP", f"godhead up — template {TEMPLATE}, LAN {LAN}")
app.run(host="0.0.0.0", port=8080, threaded=True)

View File

@@ -240,6 +240,7 @@ function renderFleet() {
</div>
<div class="flex gap-1.5 flex-wrap">
<button onclick="doTerm('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/40">TERMINAL</button>
<button onclick="doVnc('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-cyan-500/20 text-cyan-300 hover:bg-cyan-500/40">VNC</button>
<button onclick="doOnboard('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-fuchsia-500/20 text-fuchsia-300 hover:bg-fuchsia-500/40">ONBOARD</button>
<button onclick="doShell('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SHELL</button>
<button onclick="doShot('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SCREENSHOT</button>
@@ -396,6 +397,16 @@ function hoverOff() {
}
// ---------- interactive terminal ----------
function doVnc(mac) {
fetch("/api/device/" + mac + "/vnc").then(r => r.json()).then(d => {
if (d.url) {
openModal("LIVE SCREEN — " + mac.slice(0, 14), `<iframe src="${d.url}" class="w-full rounded" style="height:75vh;background:#000"></iframe>`);
} else {
openModal("LIVE SCREEN", `<div class="text-red-400">${d.error || "no VNC mapping"}</div>`);
}
});
}
function doTerm(mac) {
openModal("TERMINAL — " + mac.slice(0, 14),
`<div id="termHost" class="bg-black rounded border border-zinc-700 p-2" style="height:420px"></div>