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:
@@ -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 = '' } })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user