feat(vm): Screenshot pro VM im Desktop-Panel (VM sehen ohne Live-VNC)

Solange das Live-VNC-Bild noch hakt, ist ein Standbild der pragmatische Weg die
VM zu sehen — genau wie ARIA es beim basic_os-Test gemacht hat.
- Brain: POST /projects/<pid>/vms/<name>/screenshot → aria-vm screenshot (PNG in
  /shared/uploads) → als Base64 zurueck. End-to-end auf dem Host validiert
  (Brain-Container sieht die Datei unter /shared/uploads).
- App: 📷-Knopf pro laufender VM im DesktopTile → zeigt den Screenshot im Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:55:47 +02:00
co-authored by Claude Opus 4.8
parent 055db7c059
commit 239f1094f9
3 changed files with 68 additions and 2 deletions
+4
View File
@@ -658,6 +658,10 @@ export const brainApi = {
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
},
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
+35 -2
View File
@@ -7,7 +7,7 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi, { ProjectVm } from '../../services/brainApi';
import VncTile from './VncTile';
@@ -22,6 +22,8 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
const [err, setErr] = useState('');
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
const [connected, setConnected] = useState<ProjectVm | null>(null);
const [shotBusy, setShotBusy] = useState('');
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
const load = useCallback(() => {
setLoading(true); setErr('');
@@ -51,6 +53,14 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
.finally(() => setBusy(''));
}, [projectId, load]);
const screenshot = useCallback((vm: ProjectVm) => {
setShotBusy(vm.name); setErr('');
brainApi.screenshotProjectVm(projectId, vm.name)
.then(r => setShot({ name: vm.name, b64: r.base64 }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setShotBusy(''));
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
@@ -110,6 +120,11 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
<ActivityIndicator color="#0096FF" />
) : vm.running ? (
<>
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
{shotBusy === vm.name
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
</TouchableOpacity>
@@ -128,6 +143,20 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
})
)}
</ScrollView>
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
{shot && (
<Image
source={{ uri: `data:image/png;base64,${shot.b64}` }}
style={styles.shotImg}
resizeMode="contain"
/>
)}
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
</TouchableOpacity>
</Modal>
</View>
);
};
@@ -149,8 +178,12 @@ const styles = StyleSheet.create({
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 },
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
vmBtnText: { fontSize: 12, fontWeight: '700' },
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
});
export default DesktopTile;
+29
View File
@@ -1038,6 +1038,35 @@ def project_vm_stop(project_id: str, name: str):
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/screenshot")
def project_vm_screenshot(project_id: str, name: str):
"""Macht einen Screenshot der laufenden VM (aria-vm screenshot → PNG in
/shared/uploads) und liefert ihn als Base64 zurueck. So sieht Stefan den
VM-Bildschirm auch ohne Live-VNC (genau wie ARIA es beim Testen macht)."""
import base64
import time as _t
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
if rc != 0:
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
path = ""
for line in out.splitlines():
if line.startswith("screenshot="):
path = line.split("=", 1)[1].strip()
if not path:
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
local = os.path.join("/shared/uploads", os.path.basename(path))
for _ in range(10): # kurze Bind-Mount-Latenz abfangen
if os.path.isfile(local):
break
_t.sleep(0.2)
if not os.path.isfile(local):
raise HTTPException(status_code=500, detail=f"Screenshot-Datei nicht gefunden: {local}")
with open(local, "rb") as f:
data = f.read()
return {"ok": True, "name": name, "filename": os.path.basename(local),
"base64": base64.b64encode(data).decode("ascii")}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()