Im Hauptchat (projectId='') riefen FilesTile/DesktopTile /projects//files bzw. /vms auf → Brain 404. Beide guarden jetzt leere projectId: kein Fetch, klare Meldung "Kein aktives Projekt — wechsle in ein Projekt". tsc clean. App-only, Deploy: APK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
9.0 KiB
TypeScript
193 lines
9.0 KiB
TypeScript
/**
|
||
* 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, Image, Modal, 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 [shotBusy, setShotBusy] = useState('');
|
||
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||
|
||
const load = useCallback(() => {
|
||
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
|
||
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]);
|
||
|
||
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}>
|
||
<Text style={styles.icon}>🖥️</Text>
|
||
<Text style={styles.text}>Desktop</Text>
|
||
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
|
||
</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>
|
||
) : !projectId ? (
|
||
<Text style={styles.empty}>Kein aktives Projekt — wechsle in ein Projekt für dessen VMs.</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 style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
|
||
</Text>
|
||
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
|
||
</View>
|
||
<View style={styles.vmBtns}>
|
||
{isBusy ? (
|
||
<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>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
|
||
{connected && (
|
||
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
|
||
<View style={styles.fs}>
|
||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
|
||
<Text style={styles.fsBackText}>‹ VMs</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Modal>
|
||
)}
|
||
</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, 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, minWidth: 34, alignItems: 'center' },
|
||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||
fs: { flex: 1, backgroundColor: '#000000' },
|
||
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
|
||
fsBackText: { color: '#0096FF', fontSize: 14, 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;
|