feat: VPN peer QR code UI, consolidate CI workflows

- Add vpn.create-peer, vpn.list-peers, vpn.remove-peer RPC methods
- Generate WireGuard config + QR code (SVG) for mobile device connection
- Add "Add Device" modal on Network page with QR scanner support
- Remove old build-iso.yml (replaced by build-iso-dev.yml)
- Remove container-tests.yml (tests run in dev workflow)
- Remove container orchestration tests from dev workflow (redundant)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-07 19:44:00 +01:00
parent b0e5e8c00e
commit 4fc6c103ba
9 changed files with 319 additions and 297 deletions

View File

@@ -108,15 +108,59 @@
</div>
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<span class="text-white/80 text-sm">VPN</span>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<span class="text-white/80 text-sm">VPN</span>
</div>
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
{{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
</span>
</div>
<div v-if="networkData.vpnConnected" class="mt-3 pt-3 border-t border-white/10">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Connected Devices</span>
<button @click="showAddDeviceModal = true" class="glass-button px-3 py-1 text-xs">Add Device</button>
</div>
<div v-if="vpnPeers.length" class="space-y-1">
<div v-for="peer in vpnPeers" :key="peer.name" class="flex items-center justify-between text-xs py-1">
<span class="text-white/70">{{ peer.name }}</span>
<span class="text-white/40 font-mono">{{ peer.ip }}</span>
</div>
</div>
<div v-else class="text-xs text-white/40">No devices connected</div>
</div>
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
{{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
</span>
</div>
<!-- Add Device Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="showAddDeviceModal = false">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
<button @click="showAddDeviceModal = false" class="p-1 rounded hover:bg-white/10 text-white/60"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
<div v-if="!peerQrData">
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
</div>
<div v-else class="text-center">
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
<div class="flex gap-2">
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
<button @click="peerQrData = null; newPeerName = ''" class="flex-1 glass-button py-2 text-xs">Done</button>
</div>
</div>
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
</div>
</div>
</Transition>
</Teleport>
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" /></svg>
@@ -318,6 +362,47 @@ async function loadNetworkData() {
} catch { /* keep defaults */ } finally { networkLoading.value = false }
}
// VPN peer management
const showAddDeviceModal = ref(false)
const newPeerName = ref('')
const creatingPeer = ref(false)
const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null)
const peerError = ref('')
const copiedConfig = ref(false)
const vpnPeers = ref<{ name: string; ip: string }[]>([])
async function loadVpnPeers() {
try {
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
vpnPeers.value = res.peers || []
} catch { /* no peers */ }
}
async function createPeer() {
if (!newPeerName.value.trim()) return
creatingPeer.value = true
peerError.value = ''
try {
const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({
method: 'vpn.create-peer',
params: { name: newPeerName.value.trim() },
})
peerQrData.value = res
loadVpnPeers()
} catch (e) {
peerError.value = e instanceof Error ? e.message : 'Failed to create peer'
} finally {
creatingPeer.value = false
}
}
async function copyPeerConfig() {
if (!peerQrData.value?.config) return
try { await navigator.clipboard.writeText(peerQrData.value.config) } catch { /* fallback */ }
copiedConfig.value = true
setTimeout(() => { copiedConfig.value = false }, 2000)
}
// Network interfaces
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
interface WifiNetwork { ssid: string; signal: number; security: string }
@@ -489,7 +574,7 @@ async function createService(name: string, port: number | null) {
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
}
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices() })
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers() })
watch(showWifiModal, (open) => { if (open) scanWifi() })
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })