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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user