noVNC bridge: per-device live screens in dashboard (websockify + TokenFile + VNC button)
This commit is contained in:
12
android-vnc.service
Normal file
12
android-vnc.service
Normal 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
41
app.py
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user