archy/neode-ui/src/views/Server.vue

1133 lines
49 KiB
Vue
Raw Normal View History

2026-01-24 22:59:20 +00:00
<template>
2026-03-14 17:12:41 +00:00
<div class="pb-6">
2026-01-24 22:59:20 +00:00
<!-- 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>
2026-01-24 22:59:20 +00:00
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
2026-01-24 22:59:20 +00:00
<!-- Service Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
2026-01-24 22:59:20 +00:00
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
2026-01-24 22:59:20 +00:00
<p class="text-sm font-medium text-white">Services</p>
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
</div>
</div>
<button
@click="restartServices"
2026-03-14 17:12:41 +00:00
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
2026-01-24 22:59:20 +00:00
:disabled="restarting"
>
{{ restarting ? 'Restarting...' : 'Restart' }}
</button>
</div>
<!-- Tor Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
<div v-if="torStatusLabel === 'running'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
2026-01-24 22:59:20 +00:00
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Tor Status</p>
<p class="text-xs text-white/60 capitalize">{{ torStatusLabel }}</p>
2026-01-24 22:59:20 +00:00
</div>
</div>
<button
@click="checkTorStatus"
2026-03-14 17:12:41 +00:00
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
:disabled="checkingTor"
2026-01-24 22:59:20 +00:00
>
{{ checkingTor ? 'Checking...' : 'Check Tor' }}
2026-01-24 22:59:20 +00:00
</button>
</div>
<!-- Auto-Sync Toggle -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
2026-03-14 17:12:41 +00:00
<div class="flex items-center justify-between min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Auto-Sync</p>
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
</div>
2026-01-24 22:59:20 +00:00
</div>
<ToggleSwitch v-model="autoSyncEnabled" />
2026-01-24 22:59:20 +00:00
</div>
</div>
<!-- Logs & Diagnostics -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2026-01-24 22:59:20 +00:00
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div class="min-w-0">
2026-01-24 22:59:20 +00:00
<p class="text-sm font-medium text-white">Logs</p>
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
</div>
</div>
<button
@click="viewLogs"
2026-03-14 17:12:41 +00:00
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
2026-01-24 22:59:20 +00:00
>
View
</button>
</div>
</div>
</div>
<!-- Overview Cards -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
2026-01-24 22:59:20 +00:00
<!-- Local Network Card -->
2026-03-14 17:12:41 +00:00
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0">
2026-01-24 22:59:20 +00:00
<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">
<!-- Skeleton loading -->
<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>
2026-01-24 22:59:20 +00:00
</div>
</template>
2026-01-24 22:59:20 +00:00
<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>
2026-01-24 22:59:20 +00:00
</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>
2026-01-24 22:59:20 +00:00
</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>
2026-01-24 22:59:20 +00:00
</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 ? `${networkData.vpnProvider} (${networkData.vpnIp})` : '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>
2026-01-24 22:59:20 +00:00
</div>
2026-03-14 17:12:41 +00:00
<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">
2026-01-24 22:59:20 +00:00
Manage Local Network
</button>
</div>
<!-- Web3 Card -->
2026-03-14 17:12:41 +00:00
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0">
2026-01-24 22:59:20 +00:00
<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">
2026-01-24 22:59:20 +00:00
<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">Hosted Websites</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
2026-01-24 22:59:20 +00:00
</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="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">SSL Certificates</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
2026-01-24 22:59:20 +00:00
</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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span class="text-white/80 text-sm">IPFS Storage</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
2026-01-24 22:59:20 +00:00
</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="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-white/80 text-sm">ENS Domains</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
2026-01-24 22:59:20 +00:00
</div>
</div>
2026-03-14 17:12:41 +00:00
<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">
2026-01-24 22:59:20 +00:00
Manage Web3 Services
</button>
</div>
</div>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6">
<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 -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torDaemonRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torDaemonRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="restartTor" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
</button>
<button @click="showAddServiceModal = true" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Service
</button>
</div>
</div>
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
<div v-else class="space-y-2">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
v-if="svc.onion_address && svc.enabled"
@click="rotateService(svc.name)"
:disabled="torRotating === svc.name"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<button
v-if="svc.name !== 'archipelago'"
@click="deleteService(svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
</div>
</div>
</div>
</div>
<!-- Add Tor Service Modal -->
<Teleport to="body">
<div v-if="showAddServiceModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showAddServiceModal = false" @keydown.escape="showAddServiceModal = false">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Add Tor Hidden Service</h3>
<!-- Tabs: Installed Apps | Manual -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="addServiceTab = 'apps'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'apps' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Installed Apps
</button>
<button
@click="addServiceTab = 'manual'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'manual' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Manual
</button>
</div>
<!-- Installed Apps tab -->
<div v-if="addServiceTab === 'apps'">
<p class="text-white/60 text-sm mb-3">Select an installed app to create a .onion address for it.</p>
<div v-if="availableAppsForTor.length === 0" class="p-4 text-center text-white/40 text-sm">
All installed apps already have Tor services.
</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<button
v-for="app in availableAppsForTor"
:key="app.id"
@click="createServiceForApp(app.id)"
:disabled="addingService"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ app.title }}</p>
<p class="text-xs text-white/40">{{ app.id }}</p>
</div>
<span class="text-xs text-orange-400 shrink-0">+ Add</span>
</button>
</div>
</div>
<!-- Manual tab -->
<div v-if="addServiceTab === 'manual'">
<p class="text-white/60 text-sm mb-3">Create a .onion address for any local service.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Service Name</label>
<input
v-model="newServiceName"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="my-app"
maxlength="64"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Local Port</label>
<input
v-model.number="newServicePort"
type="number"
min="1"
max="65535"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="8080"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="createService"
:disabled="!newServiceName.trim() || !newServicePort || addingService"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ addingService ? 'Creating...' : 'Create Service' }}
</button>
<button
@click="showAddServiceModal = false"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</div>
<p v-if="addServiceError" class="mt-3 text-sm text-red-400">{{ addServiceError }}</p>
</div>
</div>
</Teleport>
</div>
<!-- WiFi Scan Modal -->
2026-03-14 17:12:41 +00:00
<div v-if="showWifiModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
<button @click="showWifiModal = false" class="text-white/40 hover:text-white transition-colors">
<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>
<template v-if="wifiScanning">
<div class="space-y-3">
<div v-for="i in 4" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-12"></div>
</div>
</template>
<template v-else-if="wifiNetworks.length > 0">
<div class="space-y-2 max-h-72 overflow-y-auto">
<button
v-for="net in wifiNetworks"
:key="net.ssid"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
@click="selectWifi(net.ssid)"
>
<div>
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
<p class="text-xs text-white/50">{{ net.security || 'Open' }}</p>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-0.5">
<div v-for="bar in 4" :key="bar" class="w-1 rounded-full" :class="bar <= Math.ceil(net.signal / 25) ? 'bg-white/80' : 'bg-white/20'" :style="{ height: (bar * 3 + 4) + 'px' }"></div>
</div>
<span class="text-xs text-white/50">{{ net.signal }}%</span>
</div>
</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
</template>
<!-- WiFi password prompt -->
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
<input
v-model="wifiPassword"
type="password"
placeholder="WiFi password"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
@keyup.enter="connectToWifi"
/>
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
<div class="flex gap-2">
<button @click="wifiConnecting = false; wifiPassword = ''; wifiError = ''" :disabled="wifiSubmitting" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
<button @click="connectToWifi" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!wifiPassword || wifiSubmitting">{{ wifiSubmitting ? 'Connecting...' : 'Connect' }}</button>
</div>
</div>
</div>
</div>
<!-- DNS Configuration Modal -->
2026-03-14 17:12:41 +00:00
<div v-if="showDnsModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showDnsModal = false">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
<button @click="showDnsModal = false" class="text-white/40 hover:text-white transition-colors">
<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>
<p class="text-sm text-white/60 mb-4">Choose a DNS provider. Providers with DoH encrypt your DNS queries.</p>
<div class="space-y-2 mb-4">
<button
v-for="opt in dnsProviderOptions"
:key="opt.value"
class="w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left"
:class="dnsSelectedProvider === opt.value ? 'bg-white/15 border border-white/20' : 'bg-white/5 border border-transparent hover:bg-white/10'"
@click="dnsSelectedProvider = opt.value; dnsCustomServers = ''"
>
<div>
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
<p class="text-xs text-white/50">{{ opt.description }}</p>
</div>
<span v-if="opt.doh" class="text-xs px-2 py-0.5 rounded-full bg-green-400/20 text-green-400">DoH</span>
</button>
</div>
<!-- Custom servers input -->
<div v-if="dnsSelectedProvider === 'custom'" class="mb-4">
<label class="block text-sm text-white/70 mb-1">DNS Servers (comma-separated)</label>
<input
v-model="dnsCustomServers"
type="text"
placeholder="1.1.1.1, 8.8.8.8"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30"
/>
</div>
<!-- Current servers info -->
<div v-if="networkData.dnsServers.length > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Current resolv.conf servers</p>
<p class="text-sm text-white/80">{{ networkData.dnsServers.join(', ') }}</p>
</div>
<p v-if="dnsError" class="text-sm text-red-400 mb-3">{{ dnsError }}</p>
<div class="flex gap-2">
<button @click="showDnsModal = false; dnsError = ''" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
<button
@click="applyDnsConfig"
class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium"
:disabled="dnsApplying || (dnsSelectedProvider === 'custom' && !dnsCustomServers.trim())"
>
{{ dnsApplying ? 'Applying...' : 'Apply' }}
</button>
</div>
</div>
</div>
<!-- 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>
2026-01-24 22:59:20 +00:00
</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 ToggleSwitch from '@/components/ToggleSwitch.vue'
2026-01-24 22:59:20 +00:00
const appStore = useAppStore()
2026-01-24 22:59:20 +00:00
// Connected nodes
const connectedNodes = ref(0)
2026-01-24 22:59:20 +00:00
// 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'
})
2026-01-24 22:59:20 +00:00
// Auto-sync toggle
const autoSyncEnabled = ref(true)
// Logs
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: '',
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 count = fwdRes.value.forwards?.length ?? 0
networkData.value.forwardCount = `${count} rule${count !== 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 ?? ''
networkData.value.vpnHostname = vpnRes.value.hostname ?? ''
networkData.value.vpnPeers = vpnRes.value.peers_connected
}
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 (e) {
if (import.meta.env.DEV) console.warn('Keep N/A defaults on failure', e)
} finally {
networkLoading.value = false
}
}
// Load peer count
async function loadPeerCount() {
try {
const res = await rpcClient.listPeers()
connectedNodes.value = res.peers?.length ?? 0
} catch {
connectedNodes.value = 0
}
}
// 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 configuration
const showDnsModal = ref(false)
const dnsSelectedProvider = ref('system')
const dnsCustomServers = ref('')
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() {
dnsApplying.value = true
dnsError.value = ''
try {
const provider = dnsSelectedProvider.value as DnsProviderValue
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
if (provider === 'custom') {
params.servers = dnsCustomServers.value
.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. Please try again.'
if (import.meta.env.DEV) console.warn('DNS configuration failed', e)
} 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() {
if (!wifiPassword.value || !wifiSelectedSsid.value) return
wifiError.value = ''
wifiSubmitting.value = true
try {
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password: wifiPassword.value } })
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. Check password and try again.'
if (import.meta.env.DEV) console.warn('WiFi connection failed', e)
} finally {
wifiSubmitting.value = false
}
}
// Disk space monitoring
const diskWarning = ref<{
level: 'warning' | 'critical'
used_percent: number
free_bytes: number
} | null>(null)
const diskCleaning = ref(false)
async function loadDiskStatus() {
try {
const res = await rpcClient.diskStatus()
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 {
// Disk status is 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)
if (import.meta.env.DEV) console.warn('Disk cleanup failed', e)
} 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 ---
interface TorServiceInfo {
name: string
local_port: number
onion_address: string | null
enabled: boolean
unauthenticated: boolean
protocol: boolean
}
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)
// Add service modal
const showAddServiceModal = ref(false)
const addServiceTab = ref<'apps' | 'manual'>('apps')
const newServiceName = ref('')
const newServicePort = ref<number | null>(null)
const addingService = ref(false)
const addServiceError = ref('')
// Installed apps that don't already have a Tor service
const availableAppsForTor = computed(() => {
const existingNames = new Set(torServices.value.map(s => s.name))
const pkgs = appStore.packages
return Object.entries(pkgs)
.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
}
}
function copyTorAddress(address: string) {
navigator.clipboard.writeText(address)
logsToast.value = 'Onion address copied to clipboard'
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 (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
}
}
async function rotateService(name: string) {
torRotating.value = name
try {
await rpcClient.call({ method: 'tor.rotate-service', params: { name }, timeout: 90000 })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)
} 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 (e) {
logsToast.value = 'Failed to restart Tor'
setTimeout(() => { logsToast.value = '' }, 5000)
if (import.meta.env.DEV) console.warn('Failed to restart Tor:', e)
} finally {
torRestarting.value = false
}
}
async function createServiceForApp(appId: string) {
addServiceError.value = ''
addingService.value = true
try {
// Backend knows the port from known_service_port() — pass 0 to let it auto-detect
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() {
const name = newServiceName.value.trim()
const port = newServicePort.value
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
newServiceName.value = ''
newServicePort.value = null
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
}
}
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 (e) {
if (import.meta.env.DEV) console.warn('Failed to delete Tor service:', e)
} finally {
torDeleting.value = false
}
}
onMounted(() => {
checkTorStatus()
loadNetworkData()
loadPeerCount()
loadInterfaces()
loadDiskStatus()
loadTorServices()
})
watch(showWifiModal, (open) => {
if (open) scanWifi()
})
2026-01-24 22:59:20 +00:00
watch(showDnsModal, (open) => {
if (open) {
dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'
dnsCustomServers.value = ''
dnsError.value = ''
}
})
async function restartServices() {
2026-01-24 22:59:20 +00:00
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)
if (import.meta.env.DEV) console.warn('Restart RPC failed', e)
}
// Poll health to confirm recovery instead of assuming success
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
}
}
2026-01-24 22:59:20 +00:00
restarting.value = false
servicesRunning.value = false
torStatusLabel.value = 'stopped'
}
pollHealth(15)
2026-01-24 22:59:20 +00:00
}
async function checkTorStatus() {
checkingTor.value = true
torStatusLabel.value = 'checking'
try {
const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' })
const services = res.services || []
torServices.value = services
torStatusLabel.value = services.some(s => s.onion_address) ? 'running' : 'stopped'
} catch {
torStatusLabel.value = 'stopped'
} finally {
checkingTor.value = false
}
2026-01-24 22:59:20 +00:00
}
const logsToast = ref('')
2026-01-24 22:59:20 +00:00
function viewLogs() {
logCount.value = 0
logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'
setTimeout(() => { logsToast.value = '' }, 6000)
2026-01-24 22:59:20 +00:00
}
</script>