archy/neode-ui/src/views/Server.vue
Dorian a029a4c948 feat: NostrVPN add-device guided wizard
Replace disconnected "Generate Invite" + "Add participant" with a 2-step
wizard: enter phone npub → get invite QR + mesh details. Backend vpn.invite
now accepts optional npub param to add participant in the same call. Modal
shows network ID, node npub, and relay URLs for manual app configuration.

Also includes nostr-vpn service hardening (rate-limit restarts, reset-failed
before enable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:04:53 +02:00

834 lines
47 KiB
Vue

<template>
<div class="pb-6">
<!-- LUKS Encryption Badge -->
<div v-if="diskEncrypted" class="mb-4 px-4 py-2.5 rounded-xl border bg-green-500/5 border-green-500/20 flex items-center gap-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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-xs text-green-300/80 font-medium">LUKS2 Encrypted Storage</span>
</div>
<!-- Disk Space Warning Banner -->
<div
v-if="diskWarning"
class="mb-6 p-4 rounded-xl border flex items-center justify-between"
:class="diskWarning.level === 'critical'
? 'bg-red-500/10 border-red-500/30'
: 'bg-yellow-500/10 border-yellow-500/30'"
>
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" :class="diskWarning.level === 'critical' ? 'text-red-400' : 'text-yellow-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<p class="text-sm font-medium" :class="diskWarning.level === 'critical' ? 'text-red-300' : 'text-yellow-300'">
{{ diskWarning.level === 'critical' ? 'Disk Space Critical' : 'Disk Space Warning' }}
</p>
<p class="text-xs text-white/60">
{{ diskWarning.used_percent.toFixed(1) }}% used {{ formatBytes(diskWarning.free_bytes) }} remaining
</p>
</div>
</div>
<button
class="glass-button glass-button-sm px-3 py-1.5 text-xs font-medium rounded"
:disabled="diskCleaning"
@click="runDiskCleanup"
>
{{ diskCleaning ? 'Cleaning...' : 'Clean Up' }}
</button>
</div>
<!-- Quick Actions -->
<QuickActionsCard
:services-running="servicesRunning"
:restarting="restarting"
:tor-status-label="torStatusLabel"
:tor-status-color="torStatusColor"
:checking-tor="checkingTor"
:auto-sync-enabled="autoSyncEnabled"
:log-count="logCount"
@restart-services="restartServices"
@check-tor="checkTorStatus"
@update:auto-sync-enabled="autoSyncEnabled = $event"
@view-logs="viewLogs"
/>
<!-- Overview Cards -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">Local Network</h2>
<p class="text-white/70 text-sm mb-4">OpenWRT-integrated router and network management</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<template v-if="networkLoading">
<div v-for="i in 4" :key="i" class="flex items-center justify-between p-3 bg-white/5 rounded-lg animate-pulse">
<div class="flex items-center gap-3">
<div class="w-5 h-5 bg-white/10 rounded"></div>
<div class="w-24 h-4 bg-white/10 rounded"></div>
</div>
<div class="w-16 h-4 bg-white/10 rounded"></div>
</div>
</template>
<template v-else>
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
<span class="text-white/80 text-sm">Firewall Active</span>
</div>
<span class="text-green-400 text-sm font-medium">Protected</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">WiFi Networks</span>
</div>
<span class="text-white/60 text-sm">{{ networkData.wifiCount }}</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="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-9m-9 9a9 9 0 019-9" /></svg>
<span class="text-white/80 text-sm">Tor</span>
</div>
<span class="text-sm" :class="torStatusLabel === 'running' ? 'text-green-400' : 'text-white/60'">{{ torStatusLabel === 'running' ? 'Connected' : torStatusLabel === 'checking' ? 'Checking...' : 'Stopped' }}</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="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">Port Forwarding</span>
</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>
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
{{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : 'Not Connected' }}
</span>
</div>
<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>
<span class="text-white/80 text-sm">DNS</span>
</div>
<span class="text-sm" :class="networkData.dnsProvider !== 'system' ? 'text-green-400' : 'text-white/60'">
{{ dnsDisplayLabel }}
</span>
</button>
</template>
</div>
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Local Network
</button>
</div>
<!-- Web3 Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" 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-9m-9 9a9 9 0 019-9" /></svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">Web3</h2>
<p class="text-white/70 text-sm mb-4">Decentralized web hosting and services</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div v-for="item in ['Hosted Websites', 'SSL Certificates', 'IPFS Storage', 'ENS Domains']" :key="item" 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="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-9m-9 9a9 9 0 019-9" /></svg>
<span class="text-white/80 text-sm">{{ item }}</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
</div>
</div>
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Web3 Services
</button>
</div>
</div>
<!-- VPN Card -->
<div class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" 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>
</div>
<div>
<h2 class="text-lg font-semibold text-white">VPN</h2>
<p class="text-xs text-white/50">WireGuard + NostrVPN mesh</p>
</div>
</div>
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
</div>
<!-- Node npub for sharing -->
<div v-if="nodeNpub" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-white/40 shrink-0">npub</span>
<span class="text-xs font-mono text-white/60 truncate">{{ nodeNpub }}</span>
</div>
<button @click="copyNpub" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedNpub ? 'Copied' : 'Copy' }}</button>
</div>
<!-- Private relay URLs for mesh VPN peer discovery -->
<div v-if="relayOnion" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-purple-400/70 shrink-0">relay (tor)</span>
<span class="text-xs font-mono text-white/60 truncate">{{ relayOnion }}</span>
</div>
<button @click="copyText(relayOnion, 'onion')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'onion' ? 'Copied' : 'Copy' }}</button>
</div>
<div v-if="relayDirect" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-white/40 shrink-0">relay (direct)</span>
<span class="text-xs font-mono text-white/60 truncate">{{ relayDirect }}</span>
</div>
<button @click="copyText(relayDirect, 'direct')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'direct' ? 'Copied' : 'Copy' }}</button>
</div>
<!-- VPN IPs -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="networkData.wgIp ? 'bg-green-400' : 'bg-white/20'"></div>
<span class="text-xs text-white/50">WireGuard</span>
</div>
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Not active' }}</span>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="networkData.vpnIp ? 'bg-green-400' : 'bg-white/20'"></div>
<span class="text-xs text-white/50">NostrVPN</span>
</div>
<span class="text-sm font-mono" :class="networkData.vpnIp ? 'text-white' : 'text-white/30'">{{ networkData.vpnIp || 'Not active' }}</span>
</div>
</div>
<!-- Connected Devices -->
<div class="border-t border-white/10 pt-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Connected Devices</span>
<span class="text-xs text-white/30">{{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}</span>
</div>
<div v-if="vpnPeers.length" class="space-y-1">
<div v-for="peer in vpnPeers" :key="peer.name + (peer.npub || '')" class="flex items-center justify-between text-xs py-1.5 px-2 bg-white/5 rounded">
<div class="flex items-center gap-2">
<span class="px-1 py-0.5 rounded text-[10px] font-medium" :class="peer.type === 'nostrvpn' ? 'bg-purple-500/20 text-purple-300' : 'bg-blue-500/20 text-blue-300'">{{ peer.type === 'nostrvpn' ? 'NVP' : 'WG' }}</span>
<button v-if="peer.type !== 'nostrvpn'" @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
<span v-else class="text-white/70">{{ peer.name }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-white/40 font-mono">{{ peer.ip?.replace(/\/\d+$/, '') || '' }}</span>
<button v-if="peer.type !== 'nostrvpn'" @click="removePeer(peer.name)" :disabled="removingPeer === peer.name" class="p-0.5 rounded hover:bg-white/10 text-white/30 hover:text-red-400 transition-colors" :title="'Remove ' + peer.name">
<svg v-if="removingPeer === peer.name" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
<svg v-else class="w-3.5 h-3.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>
</div>
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
</div>
</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="closeDeviceModal">
<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="closeDeviceModal" 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>
<!-- Loading state (for existing peer config) -->
<div v-if="loadingPeerConfig" class="text-center py-8">
<svg class="w-6 h-6 animate-spin text-white/40 mx-auto" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
</div>
<!-- Existing peer QR view -->
<div v-else-if="peerQrData && !showingNewDevice" 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="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
</div>
</div>
<!-- New device: tab selection -->
<div v-else>
<div v-if="!peerQrData && !inviteData" class="flex gap-1 mb-4 bg-white/5 rounded-lg p-1">
<button @click="deviceTab = 'nvpn'" :class="deviceTab === 'nvpn' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">NostrVPN App</button>
<button @click="deviceTab = 'wg'" :class="deviceTab === 'wg' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">WireGuard App</button>
</div>
<div v-if="deviceTab === 'nvpn'">
<!-- Step 2: QR + mesh details -->
<div v-if="inviteData" class="text-center">
<p class="text-xs text-white/40 mb-3">Step 2 of 2 Join the mesh</p>
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="inviteData.qr_svg"></div>
<p class="text-sm text-white/70 mb-4">Scan with the <strong>NostrVPN</strong> app</p>
<!-- Manual entry details -->
<div class="border-t border-white/10 pt-3 mb-4 text-left">
<button @click="showMeshDetails = !showMeshDetails" class="text-xs text-white/40 hover:text-white/60 transition-colors mb-2 flex items-center gap-1">
<svg class="w-3 h-3 transition-transform" :class="showMeshDetails ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
Or enter manually in the app
</button>
<div v-if="showMeshDetails" class="space-y-2">
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Network</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.network_id }}</span></div>
<button @click="copyText(inviteData.network_id, 'net')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'net' ? 'Copied' : 'Copy' }}</button>
</div>
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Node npub</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.npub }}</span></div>
<button @click="copyText(inviteData.npub, 'invnpub')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'invnpub' ? 'Copied' : 'Copy' }}</button>
</div>
<div v-for="(relay, i) in (inviteData.relays || [])" :key="relay" class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Relay {{ (inviteData.relays?.length || 0) > 1 ? i + 1 : '' }}</span><span class="text-xs font-mono text-white/70 truncate block">{{ relay }}</span></div>
<button @click="copyText(relay, 'relay' + i)" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'relay' + i ? 'Copied' : 'Copy' }}</button>
</div>
</div>
</div>
<div class="flex gap-2">
<button @click="copyInvite" class="flex-1 glass-button py-2 text-xs">{{ copiedInvite ? 'Copied!' : 'Copy Invite Link' }}</button>
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
</div>
</div>
<!-- Step 1: Enter phone npub -->
<div v-else>
<p class="text-xs text-white/40 mb-3">Step 1 of 2 Enter your phone's npub</p>
<p class="text-sm text-white/50 mb-3">Open the <strong class="text-white/70">NostrVPN</strong> app on your phone, go to <strong class="text-white/70">Settings</strong>, and copy your npub.</p>
<input v-model="participantNpub" type="text" placeholder="npub1..." 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 font-mono mb-3" @keyup.enter="generateInviteWithNpub" />
<button @click="generateInviteWithNpub" :disabled="generatingInvite || !participantNpub.trim().startsWith('npub1')" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ generatingInvite ? 'Setting up...' : 'Next ' }}</button>
</div>
</div>
<div v-if="deviceTab === 'wg'">
<div v-if="peerQrData" 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="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
</div>
</div>
<div v-else>
<p class="text-sm text-white/50 mb-3">Generate a static WireGuard config for the standard WireGuard app.</p>
<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>
</div>
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
</div>
</div>
</Transition>
</Teleport>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
</div>
<button
v-if="wifiAvailable"
@click="showWifiModal = true"
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Scan WiFi
</button>
</div>
<template v-if="interfacesLoading">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
</div>
</template>
<template v-else>
<div class="space-y-3">
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
<div>
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} &middot; {{ iface.mac }}</p>
</div>
</div>
<div class="text-right">
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div>
</div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
</div>
</template>
</div>
<!-- Tor Services -->
<TorServicesCard
:tor-services="torServices"
:tor-services-loading="torServicesLoading"
:tor-daemon-running="torDaemonRunning"
:tor-restarting="torRestarting"
:tor-rotating="torRotating"
:tor-deleting="torDeleting"
@restart-tor="restartTor"
@show-add-service="showAddServiceModal = true"
@copy-address="copyTorAddress"
@rotate-service="rotateService"
@delete-service="deleteService"
@toggle-app="toggleTorApp"
/>
</div>
<!-- Modals -->
<ServerModals
:show-add-service-modal="showAddServiceModal"
:show-wifi-modal="showWifiModal"
:show-dns-modal="showDnsModal"
:available-apps-for-tor="availableAppsForTor"
:adding-service="addingService"
:add-service-error="addServiceError"
:wifi-scanning="wifiScanning"
:wifi-networks="wifiNetworks"
:wifi-connecting="wifiConnecting"
:wifi-submitting="wifiSubmitting"
:wifi-selected-ssid="wifiSelectedSsid"
:wifi-error="wifiError"
:dns-selected-provider="dnsSelectedProvider"
:dns-servers="networkData.dnsServers"
:dns-applying="dnsApplying"
:dns-error="dnsError"
:dns-provider-options="dnsProviderOptions"
@close-add-service="showAddServiceModal = false"
@create-service-for-app="createServiceForApp"
@create-service="createService"
@close-wifi="showWifiModal = false"
@select-wifi="selectWifi"
@connect-wifi="connectToWifi"
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
@close-dns="showDnsModal = false; dnsError = ''"
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
@apply-dns="applyDnsConfig"
/>
<!-- Logs info toast -->
<Transition name="fade">
<div v-if="logsToast" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4">
<div class="bg-white/10 border border-white/20 backdrop-blur-sm rounded-lg px-4 py-3 text-white/80 text-sm flex items-center justify-between gap-3">
<span>{{ logsToast }}</span>
<button @click="logsToast = ''" class="text-white/50 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue'
import ServerModals from './server/ServerModals.vue'
import type { TorServiceInfo } from './server/TorServicesCard.vue'
const appStore = useAppStore()
// Service status
const servicesRunning = ref(true)
const restarting = ref(false)
// Tor status
const torStatusLabel = ref<'running' | 'stopped' | 'checking'>('checking')
const checkingTor = ref(false)
const torStatusColor = computed(() => {
if (torStatusLabel.value === 'running') return 'bg-green-400'
if (torStatusLabel.value === 'checking') return 'bg-yellow-400'
return 'bg-red-400'
})
// Auto-sync, logs
const autoSyncEnabled = ref(true)
const logCount = ref(0)
// Network data
const networkLoading = ref(true)
const networkData = ref({
wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', vpnHostname: '', vpnPeers: 0,
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
})
async function loadNetworkData() {
networkLoading.value = true
try {
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A' }
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; nodeNpub.value = vpnRes.value.node_npub ?? ''; relayOnion.value = vpnRes.value.relay_onion ?? ''; relayDirect.value = vpnRes.value.relay_direct ?? '' }
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
} catch { /* keep defaults */ } finally { networkLoading.value = false }
}
// Node npub for NostrVPN
const nodeNpub = ref('')
const copiedNpub = ref(false)
async function copyNpub() {
if (!nodeNpub.value) return
try { await navigator.clipboard.writeText(nodeNpub.value) } catch { /* fallback */ }
copiedNpub.value = true
setTimeout(() => { copiedNpub.value = false }, 2000)
}
// Private relay URLs
const relayOnion = ref('')
const relayDirect = ref('')
const copiedField = ref('')
async function copyText(text: string, field: string) {
if (!text) return
try { await navigator.clipboard.writeText(text) } catch { /* fallback */ }
copiedField.value = field
setTimeout(() => { copiedField.value = '' }, 2000)
}
// 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; type?: string; npub?: 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
}
}
const loadingPeerConfig = ref(false)
async function showPeerConfig(name: string) {
showAddDeviceModal.value = true
loadingPeerConfig.value = true
peerError.value = ''
try {
const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({
method: 'vpn.peer-config',
params: { name },
})
peerQrData.value = res
} catch (e) {
peerError.value = e instanceof Error ? e.message : 'Failed to load config'
} finally {
loadingPeerConfig.value = false
}
}
const removingPeer = ref('')
async function removePeer(name: string) {
removingPeer.value = name
try {
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name)
} catch { /* ignore */ }
finally { removingPeer.value = '' }
}
const deviceTab = ref<'nvpn' | 'wg'>('nvpn')
const showingNewDevice = ref(false)
const showMeshDetails = ref(false)
const inviteData = ref<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] } | null>(null)
const generatingInvite = ref(false)
const copiedInvite = ref(false)
const participantNpub = ref('')
function closeDeviceModal() {
showAddDeviceModal.value = false
peerQrData.value = null
inviteData.value = null
newPeerName.value = ''
peerError.value = ''
showingNewDevice.value = false
showMeshDetails.value = false
participantNpub.value = ''
}
async function generateInviteWithNpub() {
const npub = participantNpub.value.trim()
if (!npub.startsWith('npub1')) return
generatingInvite.value = true
peerError.value = ''
try {
const res = await rpcClient.call<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] }>({
method: 'vpn.invite',
params: { npub },
})
inviteData.value = res
// Add to device list immediately
const short = npub.length > 20 ? `${npub.slice(0, 12)}...${npub.slice(-6)}` : npub
vpnPeers.value.push({ name: short, ip: 'mesh', type: 'nostrvpn', npub })
loadVpnPeers()
} catch (e) {
peerError.value = e instanceof Error ? e.message : 'Failed to generate invite'
} finally {
generatingInvite.value = false
}
}
async function copyInvite() {
if (!inviteData.value?.invite_url) return
try { await navigator.clipboard.writeText(inviteData.value.invite_url) } catch { /* fallback */ }
copiedInvite.value = true
setTimeout(() => { copiedInvite.value = false }, 2000)
}
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 }
const interfacesLoading = ref(true)
const allInterfaces = ref<NetworkInterface[]>([])
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
const showWifiModal = ref(false)
const wifiScanning = ref(false)
const wifiNetworks = ref<WifiNetwork[]>([])
const wifiConnecting = ref(false)
const wifiSubmitting = ref(false)
const wifiSelectedSsid = ref('')
const wifiPassword = ref('')
const wifiError = ref('')
// DNS
const showDnsModal = ref(false)
const dnsSelectedProvider = ref('system')
const dnsApplying = ref(false)
const dnsError = ref('')
const dnsProviderOptions = [
{ value: 'system', label: 'System Default', description: 'DHCP-assigned DNS servers', doh: false },
{ value: 'cloudflare', label: 'Cloudflare', description: '1.1.1.1 / 1.0.0.1', doh: true },
{ value: 'google', label: 'Google', description: '8.8.8.8 / 8.8.4.4', doh: true },
{ value: 'quad9', label: 'Quad9', description: '9.9.9.9 / 149.112.112.112', doh: true },
{ value: 'mullvad', label: 'Mullvad', description: '194.242.2.2 (no logging)', doh: true },
{ value: 'custom', label: 'Custom', description: 'Enter your own DNS servers', doh: false },
]
type DnsProviderValue = 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
const dnsDisplayLabel = computed(() => {
const p = networkData.value.dnsProvider
const opt = dnsProviderOptions.find(o => o.value === p)
if (opt && p !== 'system') return `${opt.label}${networkData.value.dnsDoH ? ' (DoH)' : ''}`
if (networkData.value.dnsServers.length > 0) return networkData.value.dnsServers.slice(0, 2).join(', ')
return 'System Default'
})
async function applyDnsConfig(customServers: string) {
dnsApplying.value = true; dnsError.value = ''
try {
const provider = dnsSelectedProvider.value as DnsProviderValue
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
const res = await rpcClient.configureDns(params)
networkData.value.dnsProvider = res.provider; networkData.value.dnsServers = res.servers; networkData.value.dnsDoH = res.doh_enabled
showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
}
async function loadInterfaces() {
interfacesLoading.value = true
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { allInterfaces.value = [] } finally { interfacesLoading.value = false }
}
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []
try { const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' }); wifiNetworks.value = res.networks } catch { wifiNetworks.value = [] } finally { wifiScanning.value = false }
}
function selectWifi(ssid: string) { wifiSelectedSsid.value = ssid; wifiPassword.value = ''; wifiConnecting.value = true }
async function connectToWifi(password: string) {
if (!password || !wifiSelectedSsid.value) return
wifiError.value = ''; wifiSubmitting.value = true
try {
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })
showWifiModal.value = false; wifiConnecting.value = false; wifiPassword.value = ''
logsToast.value = 'WiFi connected successfully'; setTimeout(() => { logsToast.value = '' }, 4000); loadInterfaces()
} catch (e) { wifiError.value = e instanceof Error ? e.message : 'WiFi connection failed.' } finally { wifiSubmitting.value = false }
}
// Disk space
const diskWarning = ref<{ level: 'warning' | 'critical'; used_percent: number; free_bytes: number } | null>(null)
const diskEncrypted = ref(false)
const diskCleaning = ref(false)
async function loadDiskStatus() {
try {
const res = await rpcClient.diskStatus()
diskEncrypted.value = !!(res as Record<string, unknown>).encrypted
if (res.level === 'warning' || res.level === 'critical') {
diskWarning.value = { level: res.level, used_percent: res.used_percent, free_bytes: res.free_bytes }
} else { diskWarning.value = null }
} catch { /* non-critical */ }
}
async function runDiskCleanup() {
diskCleaning.value = true
try { await rpcClient.diskCleanup(); await loadDiskStatus(); logsToast.value = 'Disk cleanup completed'; setTimeout(() => { logsToast.value = '' }, 4000) }
catch (e) { logsToast.value = `Disk cleanup failed: ${e instanceof Error ? e.message : 'Unknown error'}`; setTimeout(() => { logsToast.value = '' }, 6000) }
finally { diskCleaning.value = false }
}
function formatBytes(bytes: number): string {
const gb = 1024 * 1024 * 1024; const mb = 1024 * 1024
if (bytes >= gb) return `${(bytes / gb).toFixed(1)} GB`
if (bytes >= mb) return `${(bytes / mb).toFixed(0)} MB`
return `${(bytes / 1024).toFixed(0)} KB`
}
// Tor Services
const torServices = ref<TorServiceInfo[]>([])
const torServicesLoading = ref(false)
const torDaemonRunning = ref(false)
const torRestarting = ref(false)
const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false)
const showAddServiceModal = ref(false)
const addingService = ref(false)
const addServiceError = ref('')
const availableAppsForTor = computed(() => {
const existingNames = new Set(torServices.value.map(s => s.name))
return Object.entries(appStore.packages)
.filter(([id]) => !existingNames.has(id))
.map(([id, pkg]) => ({ id, title: (pkg as { manifest?: { title?: string } })?.manifest?.title || id }))
.sort((a, b) => a.title.localeCompare(b.title))
})
async function loadTorServices() {
torServicesLoading.value = true
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
catch { torServices.value = []; torDaemonRunning.value = false } finally { torServicesLoading.value = false }
}
async function copyTorAddress(address: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(address)
} else {
const ta = document.createElement('textarea')
ta.value = address
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
logsToast.value = 'Onion address copied to clipboard'
} catch {
logsToast.value = 'Failed to copy address'
}
setTimeout(() => { logsToast.value = '' }, 3000)
}
async function toggleTorApp(appId: string, enabled: boolean) { try { await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled }, timeout: 90000 }); await loadTorServices() } catch { /* handled */ } }
async function rotateService(name: string) { torRotating.value = name; try { await rpcClient.call({ method: 'tor.rotate-service', params: { name }, timeout: 90000 }); await loadTorServices() } catch { /* handled */ } finally { torRotating.value = false } }
async function restartTor() { torRestarting.value = true; try { await rpcClient.call({ method: 'tor.restart', timeout: 90000 }); await loadTorServices(); logsToast.value = 'Tor restarted successfully'; setTimeout(() => { logsToast.value = '' }, 3000) } catch { logsToast.value = 'Failed to restart Tor'; setTimeout(() => { logsToast.value = '' }, 5000) } finally { torRestarting.value = false } }
async function deleteService(name: string) { torDeleting.value = name; try { await rpcClient.call({ method: 'tor.delete-service', params: { name }, timeout: 90000 }); await loadTorServices(); logsToast.value = `Tor service "${name}" deleted`; setTimeout(() => { logsToast.value = '' }, 3000) } catch { /* handled */ } finally { torDeleting.value = false } }
async function createServiceForApp(appId: string) {
addServiceError.value = ''; addingService.value = true
try { await rpcClient.call({ method: 'tor.create-service', params: { name: appId, local_port: 0 }, timeout: 90000 }); showAddServiceModal.value = false; await loadTorServices(); logsToast.value = `Tor service for "${appId}" created`; setTimeout(() => { logsToast.value = '' }, 3000) }
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
}
async function createService(name: string, port: number | null) {
if (!name || !port) return
addServiceError.value = ''; addingService.value = true
try { await rpcClient.call({ method: 'tor.create-service', params: { name, local_port: port }, timeout: 90000 }); showAddServiceModal.value = false; await loadTorServices(); logsToast.value = `Tor service "${name}" created`; setTimeout(() => { logsToast.value = '' }, 3000) }
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
}
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 = '' } })
async function restartServices() {
restarting.value = true; servicesRunning.value = false
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
catch (e) { logsToast.value = `Restart failed: ${e instanceof Error ? e.message : 'Unknown error'}`; setTimeout(() => { logsToast.value = '' }, 6000) }
const pollHealth = async (retries: number) => {
for (let i = 0; i < retries; i++) {
await new Promise(r => setTimeout(r, 2000))
try { await rpcClient.call({ method: 'server.health', params: {} }); servicesRunning.value = true; restarting.value = false; return } catch { /* still restarting */ }
}
restarting.value = false; servicesRunning.value = false; torStatusLabel.value = 'stopped'
}
pollHealth(15)
}
async function checkTorStatus() {
checkingTor.value = true; torStatusLabel.value = 'checking'
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' }
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false }
}
const logsToast = ref('')
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
</script>