feat: deploy-to-target supports .253 + mesh/federation/VPN updates
- Add deploy_secondary() function for deploying to multiple LAN nodes - --both now deploys to .198 and .253 (previously .198 only) - Fleet deploy updated for 3 LAN nodes - Mesh DM fixes: protocol frame format, DM-via-channel routing - Federation pending requests, discover modal - VPN status UI improvements - Image versions and container specs updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e210376e05
commit
9dd802998c
@@ -15,6 +15,21 @@ export interface RPCResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend.
|
||||
export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired'
|
||||
|
||||
export interface PendingPeerRequest {
|
||||
id: string
|
||||
from_nostr_pubkey: string
|
||||
from_nostr_npub: string
|
||||
from_did: string
|
||||
from_name: string | null
|
||||
message: string | null
|
||||
received_at: string
|
||||
state: PendingState
|
||||
outbound: boolean
|
||||
}
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
|
||||
if (match) return match[1]!
|
||||
@@ -262,7 +277,7 @@ class RPCClient {
|
||||
|
||||
// ─── Node Identity ───────────────────────────────────────────────
|
||||
|
||||
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
|
||||
async getNodeDid(): Promise<{ did: string; pubkey: string; nostr_pubkey?: string; nostr_npub?: string }> {
|
||||
return this.call({
|
||||
method: 'node.did',
|
||||
params: {},
|
||||
@@ -579,6 +594,21 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async meshContactsList(): Promise<{
|
||||
contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }>
|
||||
}> {
|
||||
return this.call({ method: 'mesh.contacts-list', params: {} })
|
||||
}
|
||||
|
||||
async meshContactsSave(
|
||||
pubkey: string,
|
||||
alias?: string | null,
|
||||
): Promise<{ saved: boolean; pubkey: string; alias: string | null }> {
|
||||
const params: Record<string, unknown> = { pubkey }
|
||||
if (alias !== undefined) params.alias = alias
|
||||
return this.call({ method: 'mesh.contacts-save', params })
|
||||
}
|
||||
|
||||
async federationListNodes(): Promise<{
|
||||
nodes: Array<{
|
||||
did: string
|
||||
@@ -590,6 +620,7 @@ class RPCClient {
|
||||
last_seen?: string
|
||||
last_state?: {
|
||||
timestamp: string
|
||||
node_name?: string
|
||||
apps: Array<{ id: string; status: string; version?: string }>
|
||||
cpu_usage_percent?: number
|
||||
mem_used_bytes?: number
|
||||
@@ -598,6 +629,7 @@ class RPCClient {
|
||||
disk_total_bytes?: number
|
||||
uptime_secs?: number
|
||||
tor_active?: boolean
|
||||
nostr_npub?: string
|
||||
}
|
||||
}>
|
||||
}> {
|
||||
@@ -624,6 +656,92 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Nostr peer-discovery — see `core/archipelago/src/nostr_handshake.rs`.
|
||||
// None of these methods ever exchange the local onion address on a public
|
||||
// relay. `handshake.discover` returns presence-only events (DID + npub);
|
||||
// `handshake.connect` ships a NIP-44-encrypted PeerRequest with no onion;
|
||||
// `handshake.poll` queues inbound requests into the federation pending
|
||||
// inbox for manual approval (it does NOT auto-accept).
|
||||
|
||||
async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> {
|
||||
return this.call({ method: 'nostr.discovery-status', params: {} })
|
||||
}
|
||||
|
||||
async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
|
||||
return this.call({
|
||||
method: 'nostr.set-discovery',
|
||||
params: { enabled },
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
async handshakeDiscover(): Promise<{
|
||||
nodes: Array<{
|
||||
nostr_pubkey: string
|
||||
nostr_npub: string
|
||||
did: string
|
||||
version: string
|
||||
}>
|
||||
}> {
|
||||
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
|
||||
}
|
||||
|
||||
async handshakeConnect(
|
||||
recipient: string,
|
||||
message?: string,
|
||||
name?: string,
|
||||
): Promise<{ ok: boolean; sent_to: string; id: string }> {
|
||||
return this.call({
|
||||
method: 'handshake.connect',
|
||||
params: {
|
||||
recipient_nostr_pubkey: recipient,
|
||||
...(message ? { message } : {}),
|
||||
...(name ? { name } : {}),
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
async handshakePoll(): Promise<{
|
||||
polled: number
|
||||
new_requests: PendingPeerRequest[]
|
||||
applied_invites: string[]
|
||||
rejected_outbound: string[]
|
||||
skipped: string[]
|
||||
}> {
|
||||
return this.call({ method: 'handshake.poll', params: {}, timeout: 30000 })
|
||||
}
|
||||
|
||||
async federationListPendingRequests(): Promise<{
|
||||
requests: PendingPeerRequest[]
|
||||
}> {
|
||||
return this.call({ method: 'federation.list-pending-requests', params: {} })
|
||||
}
|
||||
|
||||
async federationApproveRequest(id: string): Promise<{ approved: boolean; id: string }> {
|
||||
return this.call({
|
||||
method: 'federation.approve-request',
|
||||
params: { id },
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
async federationRejectRequest(
|
||||
id: string,
|
||||
reason?: string,
|
||||
notify = false,
|
||||
): Promise<{ rejected: boolean; id: string }> {
|
||||
return this.call({
|
||||
method: 'federation.reject-request',
|
||||
params: {
|
||||
id,
|
||||
...(reason ? { reason } : {}),
|
||||
notify,
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
async federationSyncState(): Promise<{
|
||||
synced: number
|
||||
failed: number
|
||||
|
||||
@@ -45,6 +45,54 @@
|
||||
@clear-invite="inviteCode = ''"
|
||||
/>
|
||||
|
||||
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
|
||||
Renders inline so the Federation page is the single place a user
|
||||
manages everything related to peering. The toggle directly mutates
|
||||
the `nostr_discovery_enabled` config flag — backend defaults to OFF
|
||||
and nothing is published until the user explicitly turns it on. -->
|
||||
<div class="glass-card p-4 mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">Nostr discoverability</span>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
|
||||
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mt-1">
|
||||
When on, this node publishes a presence event (DID + npub only — never an onion)
|
||||
so other nodes can find you and request to peer. Inbound requests land in the
|
||||
panel below for your approval. Off by default.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discoveryToggling"
|
||||
@click="toggleDiscovery"
|
||||
>
|
||||
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="!discoveryEnabled"
|
||||
@click="showDiscoverModal = true"
|
||||
>
|
||||
Discover Nodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
|
||||
|
||||
<PendingRequestsPanel
|
||||
:requests="pendingRequests"
|
||||
:polling="pollingHandshake"
|
||||
:busy-id="pendingBusyId"
|
||||
@poll="pollHandshake"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
/>
|
||||
|
||||
<NodeList
|
||||
:nodes="nodes"
|
||||
:loading="loading"
|
||||
@@ -82,6 +130,13 @@
|
||||
@close="showJoinModal = false"
|
||||
@join="joinFederation"
|
||||
/>
|
||||
|
||||
<DiscoverModal
|
||||
:visible="showDiscoverModal"
|
||||
:outbound-sent="pendingRequests"
|
||||
@close="showDiscoverModal = false"
|
||||
@sent="loadPendingRequests"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -97,7 +152,10 @@ import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
import NodeDetailModal from './federation/NodeDetailModal.vue'
|
||||
import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
@@ -205,6 +263,89 @@ const rotateSuccess = ref('')
|
||||
// Dead node cleanup
|
||||
const cleaningNodes = ref(false)
|
||||
|
||||
// Nostr discoverability + pending peer requests
|
||||
const discoveryEnabled = ref(false)
|
||||
const discoveryToggling = ref(false)
|
||||
const discoveryError = ref('')
|
||||
const showDiscoverModal = ref(false)
|
||||
const pendingRequests = ref<PendingPeerRequest[]>([])
|
||||
const pollingHandshake = ref(false)
|
||||
const pendingBusyId = ref<string | null>(null)
|
||||
|
||||
async function loadDiscoveryState() {
|
||||
try {
|
||||
const result = await rpcClient.nostrDiscoveryStatus()
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch {
|
||||
discoveryEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiscovery() {
|
||||
discoveryToggling.value = true
|
||||
discoveryError.value = ''
|
||||
const next = !discoveryEnabled.value
|
||||
try {
|
||||
const result = await rpcClient.nostrSetDiscovery(next)
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
|
||||
} finally {
|
||||
discoveryToggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const result = await rpcClient.federationListPendingRequests()
|
||||
pendingRequests.value = result.requests
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests'
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHandshake() {
|
||||
pollingHandshake.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.handshakePoll()
|
||||
await loadPendingRequests()
|
||||
// If a poll applied a PeerInvite, the federation node list also changed.
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Poll failed'
|
||||
} finally {
|
||||
pollingHandshake.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approvePending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationApproveRequest(id)
|
||||
await loadPendingRequests()
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Approve failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationRejectRequest(id)
|
||||
await loadPendingRequests()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Reject failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isOnlineCheck(node: FederatedNode): boolean {
|
||||
if (!node.last_seen) return false
|
||||
const lastSeen = new Date(node.last_seen).getTime()
|
||||
@@ -382,6 +523,8 @@ async function rotateDid(password: string) {
|
||||
onMounted(async () => {
|
||||
loadNodes()
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
transportStore.fetchPeers()
|
||||
try {
|
||||
const result = await rpcClient.getNodeDid()
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
<div 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="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? (vpnStatus.provider || 'Connected') : 'Not configured' }}</span>
|
||||
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
|
||||
</div>
|
||||
<div 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="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
|
||||
+72
-204
@@ -114,7 +114,7 @@
|
||||
<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' }}
|
||||
{{ networkData.vpnConnected ? 'WireGuard' : '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">
|
||||
@@ -160,8 +160,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- VPN Card -->
|
||||
<div class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
|
||||
<div class="glass-card p-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">
|
||||
@@ -169,53 +170,20 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">VPN</h2>
|
||||
<p class="text-xs text-white/50">WireGuard + NostrVPN mesh</p>
|
||||
<p class="text-xs text-white/50">Standalone WireGuard VPN</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 || (networkData.vpnConnected ? 'Pair a device' : 'Not active') }}</span>
|
||||
<!-- WireGuard Status -->
|
||||
<div class="mb-4 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">Server Address</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Starting...' }}</span>
|
||||
<span v-if="networkData.wgPubkey" class="block text-xs font-mono text-white/30 mt-1 truncate">{{ networkData.wgPubkey }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Connected Devices -->
|
||||
@@ -225,15 +193,14 @@
|
||||
<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 v-for="peer in vpnPeers" :key="peer.name" 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>
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-300">WG</span>
|
||||
<button @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
|
||||
</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">
|
||||
<button @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>
|
||||
@@ -244,6 +211,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces (second column on desktop) -->
|
||||
<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 text-white font-medium">{{ iface.name }}</p>
|
||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ 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>
|
||||
|
||||
</div><!-- close VPN+Network 2-col grid -->
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
@@ -268,54 +282,10 @@
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New device: tab selection -->
|
||||
<!-- New device: WireGuard config -->
|
||||
<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 v-if="peerQrData">
|
||||
<div 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>
|
||||
@@ -324,8 +294,10 @@
|
||||
<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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<p class="text-sm text-white/50 mb-3">Generate a 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>
|
||||
@@ -337,52 +309,7 @@
|
||||
</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' }} · {{ 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>
|
||||
|
||||
<div class="mb-6">
|
||||
<!-- Tor Services -->
|
||||
<TorServicesCard
|
||||
:tor-services="torServices"
|
||||
@@ -475,7 +402,7 @@ const logCount = ref(0)
|
||||
const networkLoading = ref(true)
|
||||
const networkData = ref({
|
||||
wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A',
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', vpnHostname: '', vpnPeers: 0,
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
||||
})
|
||||
|
||||
@@ -490,32 +417,11 @@ async function loadNetworkData() {
|
||||
])
|
||||
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 (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 ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||
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('')
|
||||
@@ -578,52 +484,14 @@ async function removePeer(name: string) {
|
||||
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() {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'routstr': 8200,
|
||||
'indeedhub': 7778,
|
||||
'botfights': 9100,
|
||||
'gitea': 3001,
|
||||
'gitea': 3000,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
|
||||
const R = '23.182.128.160:3000/lfg2025'
|
||||
const R = 'git.tx1138.com/lfg2025'
|
||||
|
||||
// ---------- Dynamic catalog from registry ----------
|
||||
export interface CatalogFeatured {
|
||||
@@ -24,9 +24,9 @@ const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Remote catalog URLs — tried in order. First success wins. */
|
||||
const CATALOG_URLS = [
|
||||
// Primary: Gitea raw file (dynamic, updated without frontend rebuild)
|
||||
// Legacy (down): https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json
|
||||
// Fallback: direct IP if DNS fails
|
||||
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
|
||||
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Fallback: direct IP (HTTP, only works if CSP allows http://$host:*)
|
||||
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||
// Last resort: local static file (baked into frontend build)
|
||||
'/catalog.json',
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
|
||||
<p class="text-xs text-white/60 mt-1">
|
||||
Browses Nostr presence events from configured relays. Sending a
|
||||
peer request never reveals your onion — only your DID + npub +
|
||||
an optional message travel inside an encrypted DM.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="text-white/40 hover:text-white/70 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>
|
||||
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ loading ? 'Searching…' : 'Search Relays' }}
|
||||
</button>
|
||||
<span v-if="lastSearchAt" class="text-[11px] text-white/40">
|
||||
Last search: {{ lastSearchAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div>
|
||||
|
||||
<!-- Manual entry: paste an npub directly -->
|
||||
<div class="mb-6 p-3 bg-white/5 rounded-lg border border-white/10">
|
||||
<p class="text-xs text-white/60 mb-2">
|
||||
Already know an npub? Send a peer request directly.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
v-model="manualNpub"
|
||||
placeholder="npub1…"
|
||||
class="flex-1 bg-black/30 text-white text-xs rounded px-3 py-2 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="!manualNpub.trim() || sendingTo === manualNpub.trim()"
|
||||
@click="sendDirect()"
|
||||
>
|
||||
Send Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="nodes.length === 0 && !loading" class="text-center py-8 text-white/40 text-sm">
|
||||
No discoverable nodes found. Either no peers are advertising on the
|
||||
configured relays, or your discoverability hasn't been enabled long
|
||||
enough for relays to gossip yours.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.nostr_pubkey"
|
||||
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white truncate">
|
||||
{{ shortNpub(node.nostr_npub) }}
|
||||
</div>
|
||||
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
|
||||
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
|
||||
:disabled="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)"
|
||||
@click="sendTo(node)"
|
||||
>
|
||||
{{ statusFor(node) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
|
||||
|
||||
interface DiscoverableNode {
|
||||
nostr_pubkey: string
|
||||
nostr_npub: string
|
||||
did: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/// Outbound rows from the parent's pending list, used to grey out
|
||||
/// "Send Request" buttons for npubs we've already requested.
|
||||
outboundSent: PendingPeerRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
/// Fired after a successful send so the parent can refresh its
|
||||
/// pending-requests list to show the new "Sent" row.
|
||||
sent: []
|
||||
}>()
|
||||
|
||||
const nodes = ref<DiscoverableNode[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastSearchAt = ref('')
|
||||
const sendingTo = ref<string | null>(null)
|
||||
const manualNpub = ref('')
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v && nodes.value.length === 0) refresh()
|
||||
},
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await rpcClient.handshakeDiscover()
|
||||
nodes.value = result.nodes
|
||||
lastSearchAt.value = new Date().toLocaleTimeString()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTo(node: DiscoverableNode) {
|
||||
await sendInternal(node.nostr_pubkey)
|
||||
}
|
||||
|
||||
async function sendDirect() {
|
||||
const v = manualNpub.value.trim()
|
||||
if (!v) return
|
||||
await sendInternal(v)
|
||||
manualNpub.value = ''
|
||||
}
|
||||
|
||||
async function sendInternal(target: string) {
|
||||
sendingTo.value = target
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.handshakeConnect(target)
|
||||
emit('sent')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Send failed'
|
||||
} finally {
|
||||
sendingTo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function alreadySentTo(npubHex: string): boolean {
|
||||
return props.outboundSent.some(
|
||||
(r) => r.outbound && r.from_nostr_pubkey === npubHex && r.state === 'sent',
|
||||
)
|
||||
}
|
||||
|
||||
function statusFor(node: DiscoverableNode): string {
|
||||
if (sendingTo.value === node.nostr_pubkey) return 'Sending…'
|
||||
if (alreadySentTo(node.nostr_pubkey)) return 'Already sent'
|
||||
return 'Send Request'
|
||||
}
|
||||
|
||||
function shortNpub(npub: string): string {
|
||||
if (!npub || npub.length < 16) return npub
|
||||
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
|
||||
<p class="text-xs text-white/60">
|
||||
Inbound requests await your approval. Outbound requests show what you've sent.
|
||||
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
|
||||
to Trusted manually if you want them to receive state syncs.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/80 hover:text-white disabled:opacity-50"
|
||||
:disabled="polling"
|
||||
@click="$emit('poll')"
|
||||
>
|
||||
{{ polling ? 'Polling…' : 'Poll Now' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="req in visibleRequests"
|
||||
:key="req.id"
|
||||
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||
:class="badgeClass(req)"
|
||||
>{{ badgeLabel(req) }}</span>
|
||||
<span class="text-sm text-white font-medium truncate">
|
||||
{{ req.from_name || shortNpub(req.from_nostr_npub) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/50 font-mono truncate">{{ req.from_nostr_npub }}</div>
|
||||
<div v-if="req.from_did" class="text-[11px] text-white/40 font-mono truncate">{{ req.from_did }}</div>
|
||||
<p v-if="req.message" class="mt-2 text-xs text-white/70 italic">"{{ req.message }}"</p>
|
||||
<p class="mt-1 text-[10px] text-white/40">{{ relative(req.received_at) }}</p>
|
||||
</div>
|
||||
<div v-if="req.state === 'pending' && !req.outbound" class="flex flex-col gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-green-300 hover:text-green-200 disabled:opacity-50"
|
||||
:disabled="busyId === req.id"
|
||||
@click="$emit('approve', req.id)"
|
||||
>
|
||||
{{ busyId === req.id ? '…' : 'Approve' }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-red-300 hover:text-red-200 disabled:opacity-50"
|
||||
:disabled="busyId === req.id"
|
||||
@click="$emit('reject', req.id)"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{
|
||||
requests: PendingPeerRequest[]
|
||||
polling: boolean
|
||||
busyId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
poll: []
|
||||
approve: [id: string]
|
||||
reject: [id: string]
|
||||
}>()
|
||||
|
||||
// Hide already-handled rows older than 24h to keep the panel from growing
|
||||
// indefinitely. The backend keeps the full audit trail in the file; the UI
|
||||
// only shows what's actionable or recent.
|
||||
const visibleRequests = computed(() => {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
||||
return props.requests.filter((r) => {
|
||||
if (r.state === 'pending' || r.state === 'sent') return true
|
||||
const ts = new Date(r.received_at).getTime()
|
||||
return Number.isFinite(ts) && ts >= cutoff
|
||||
})
|
||||
})
|
||||
|
||||
function badgeClass(r: PendingPeerRequest): string {
|
||||
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
|
||||
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
|
||||
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
|
||||
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
|
||||
return 'bg-white/10 text-white/50'
|
||||
}
|
||||
|
||||
function badgeLabel(r: PendingPeerRequest): string {
|
||||
if (r.outbound && r.state === 'sent') return 'Sent'
|
||||
if (r.outbound && r.state === 'approved') return 'They approved'
|
||||
if (r.outbound && r.state === 'rejected') return 'They rejected'
|
||||
if (r.outbound) return r.state
|
||||
if (r.state === 'pending') return 'Inbound'
|
||||
return r.state
|
||||
}
|
||||
|
||||
function shortNpub(npub: string): string {
|
||||
if (!npub || npub.length < 16) return npub
|
||||
return `${npub.slice(0, 12)}…${npub.slice(-6)}`
|
||||
}
|
||||
|
||||
function relative(iso: string): string {
|
||||
const ts = new Date(iso).getTime()
|
||||
if (!Number.isFinite(ts)) return iso
|
||||
const diff = Date.now() - ts
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return `${sec}s ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
const day = Math.floor(hr / 24)
|
||||
return `${day}d ago`
|
||||
}
|
||||
</script>
|
||||
@@ -70,7 +70,7 @@ onMounted(fetchVpnStatus)
|
||||
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Provider</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'nostr-vpn' }}</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Peers</div>
|
||||
|
||||
Reference in New Issue
Block a user