feat: VM-Liste pro Projekt (Desktop-Panel) — Start/Stop/Verbinden

- Brain: project_vms.py (Registry /shared/config/project_vms.json pro Projekt) +
  Endpoints GET/POST/DELETE /projects/<id>/vms + boot/stop (via SSH aria-wohnung
  aria-vm auf dem Host; Status aus `aria-vm list`).
- ARIA-Tools vm_register/vm_list (aufs Request-Projekt) + Seed-Regel: nach dem
  VM-Bau registrieren, damit sie in Stefans Desktop-Panel auftaucht.
- App: brainApi VM-Methoden + ProjectVm-Typ. Neues DesktopTile — pro Projekt die
  VM-Liste (leer bis registriert), Start/Stop/Verbinden; Verbinden oeffnet noVNC
  (VncTile mit dem VNC-Port der VM). Deck rendert DesktopTile statt VncTile.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:25:55 +02:00
co-authored by Claude Opus 4.8
parent 5fa5d79ad8
commit 0efb8d0848
8 changed files with 429 additions and 5 deletions
+93
View File
@@ -39,6 +39,7 @@ import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
import project_vms as project_vms_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -894,6 +895,98 @@ def project_file(project_id: str, path: str):
return {"projectId": project_id, "path": path, "content": content}
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
def _ssh_aria_vm(*args: str, timeout: int = 25):
import subprocess
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc:
return 1, "", str(exc)
def _vm_running_names() -> set:
rc, out, _err = _ssh_aria_vm("list", timeout=15)
names = set()
if rc == 0:
for line in out.splitlines():
parts = line.split()
if parts and "laeuft" in line:
names.add(parts[0])
return names
class VmAddBody(BaseModel):
name: str
arch: str = "i386"
iso: str = ""
vnc_display: int = 1
mem: int = 1024
create_disk: bool = False
size: str = "10G"
@app.get("/projects/{project_id}/vms")
def project_vms_list(project_id: str):
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
running = _vm_running_names()
for v in vms:
v["running"] = v.get("name") in running
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
return {"projectId": project_id, "vms": vms}
@app.post("/projects/{project_id}/vms")
def project_vm_add(project_id: str, body: VmAddBody):
try:
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
body.vnc_display, body.mem)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if body.create_disk:
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
vm["create_result"] = out.strip() or err.strip()
vm["create_ok"] = (rc == 0)
return vm
@app.delete("/projects/{project_id}/vms/{name}")
def project_vm_remove(project_id: str, name: str, purge: bool = False):
ok = project_vms_mod.remove_vm(project_id, name)
if not ok:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
if purge:
_ssh_aria_vm("rm", name)
return {"ok": True, "name": name}
@app.post("/projects/{project_id}/vms/{name}/boot")
def project_vm_boot(project_id: str, name: str):
vm = project_vms_mod.get_vm(project_id, name)
if not vm:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
args = ["boot", name, "--vnc-display", str(vm.get("vnc_display", 1)),
"--mem", str(vm.get("mem", 1024))]
if vm.get("iso"):
args += ["--iso", vm["iso"]]
rc, out, err = _ssh_aria_vm(*args, timeout=40)
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
"output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/stop")
def project_vm_stop(project_id: str, name: str):
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()