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:
@@ -176,6 +176,18 @@ export interface ProjectStatus {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
/** QEMU-VM eines Projekts (Registry + Live-Status). */
|
||||
export interface ProjectVm {
|
||||
name: string;
|
||||
arch: string;
|
||||
iso?: string;
|
||||
vnc_display: number;
|
||||
mem: number;
|
||||
running?: boolean;
|
||||
vnc_port?: number;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||
export interface QueueContextStatus {
|
||||
@@ -626,6 +638,23 @@ export const brainApi = {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
|
||||
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
|
||||
},
|
||||
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
|
||||
},
|
||||
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
|
||||
},
|
||||
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
|
||||
},
|
||||
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
|
||||
},
|
||||
|
||||
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
||||
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import WorkspaceDock from './WorkspaceDock';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
import DesktopTile from './tiles/DesktopTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -58,7 +58,7 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* DesktopTile — das Desktop-Panel eines Code-Projekts.
|
||||
*
|
||||
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
|
||||
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
|
||||
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import brainApi, { ProjectVm } from '../../services/brainApi';
|
||||
import VncTile from './VncTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [vms, setVms] = useState<ProjectVm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
||||
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr('');
|
||||
brainApi.listProjectVms(projectId)
|
||||
.then(r => setVms(r.vms || []))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused && !connected) load();
|
||||
}, [focused, projectId, connected, load]);
|
||||
|
||||
const boot = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.bootProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
const stop = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.stopProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Verbunden → noVNC-Ansicht der VM + Zurück-Leiste.
|
||||
if (connected) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<TouchableOpacity onPress={() => setConnected(null)} style={styles.barBtn}>
|
||||
<Text style={styles.barBtnText}>‹ VMs</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.barTitle} numberOfLines={1}>{connected.name} · :{connected.vnc_display}</Text>
|
||||
</View>
|
||||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
|
||||
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={{ padding: 12 }}>
|
||||
{loading && vms.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : vms.length === 0 ? (
|
||||
<Text style={styles.empty}>
|
||||
Noch keine VM in diesem Projekt.{'\n'}
|
||||
Sag ARIA z.B. „bau eine QEMU-VM zum Testen" — sie registriert sie hier,
|
||||
dann kannst du sie starten und verbinden.
|
||||
</Text>
|
||||
) : (
|
||||
vms.map(vm => {
|
||||
const isBusy = busy === vm.name;
|
||||
return (
|
||||
<View key={vm.name} style={styles.vmRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.vmName}>
|
||||
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
|
||||
</Text>
|
||||
<Text style={styles.vmMeta}>{vm.arch} · Display :{vm.vnc_display} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
|
||||
</View>
|
||||
<View style={styles.vmBtns}>
|
||||
{isBusy ? (
|
||||
<ActivityIndicator color="#0096FF" />
|
||||
) : vm.running ? (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
|
||||
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
|
||||
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
|
||||
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
|
||||
vmMeta: { color: '#8888AA', fontSize: 12, marginTop: 3 },
|
||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 },
|
||||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default DesktopTile;
|
||||
@@ -16,9 +16,10 @@ import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||
}
|
||||
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||
@@ -46,7 +47,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
});
|
||||
desktop.openVnc(projectId);
|
||||
desktop.openVnc(projectId, port);
|
||||
} else if (m.event === 'vnc_send') {
|
||||
desktop.sendInput(m.b64);
|
||||
} else if (m.event === 'vnc_close') {
|
||||
@@ -55,7 +56,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
if (m.state === 'connected') setStatus('connected');
|
||||
else if (m.state === 'disconnected') setStatus('disconnected');
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, port]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user