576 lines
17 KiB
Vue
576 lines
17 KiB
Vue
<template>
|
|
<div class="pb-6">
|
|
<FederationHeader
|
|
:self-did="selfDid"
|
|
:server-name="appStore.serverName"
|
|
@rotate="showRotateModal = true"
|
|
/>
|
|
|
|
<RotateDidModal
|
|
:visible="showRotateModal"
|
|
:rotating="rotatingDid"
|
|
:error="rotateError"
|
|
:success="rotateSuccess"
|
|
@close="showRotateModal = false; rotateError = ''; rotateSuccess = ''"
|
|
@rotate="rotateDid"
|
|
/>
|
|
|
|
<!-- View Tabs -->
|
|
<div v-if="nodes.length > 0" class="flex gap-1 mb-6 p-1 bg-black/20 rounded-lg w-fit">
|
|
<button
|
|
v-for="tab in viewTabs"
|
|
:key="tab.id"
|
|
class="px-4 py-2 rounded text-sm font-medium transition-colors"
|
|
:class="activeView === tab.id ? 'bg-white/10 text-white border-b-2 border-orange-400' : 'text-white/50 hover:text-white/70'"
|
|
@click="setView(tab.id)"
|
|
>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Network Map View -->
|
|
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
|
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
|
</div>
|
|
|
|
<template v-if="activeView === 'list'">
|
|
<QuickActions
|
|
:generating-invite="generatingInvite"
|
|
:invite-type="inviteType"
|
|
:invite-code="inviteCode"
|
|
:syncing="syncing"
|
|
@generate-invite="handleGenerateInvite"
|
|
@show-join="showJoinModal = true"
|
|
@sync="syncAll"
|
|
@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"
|
|
@cancel="cancelPending"
|
|
/>
|
|
|
|
<NodeList
|
|
:nodes="nodes"
|
|
:loading="loading"
|
|
:error="error"
|
|
:sync-results="syncResults"
|
|
:dwn-sync-dot-class="dwnSyncDotClass"
|
|
:cleaning-nodes="cleaningNodes"
|
|
@select-node="selectedNode = $event"
|
|
@clear-sync-results="syncResults = []"
|
|
@cleanup-dead="cleanupDeadNodes"
|
|
/>
|
|
</template>
|
|
|
|
<NodeDetailModal
|
|
:node="selectedNode"
|
|
:dwn-sync-dot-class="dwnSyncDotClass"
|
|
:dwn-sync-label="dwnSyncLabel"
|
|
:dwn-message-count="String(dwnStatus?.message_count ?? '--')"
|
|
:dwn-last-sync="dwnStatus?.last_sync ? timeAgo(dwnStatus.last_sync) : 'never'"
|
|
:dwn-syncing="dwnSyncing"
|
|
:deploying="deploying"
|
|
:deploy-result="deployResult"
|
|
@close="selectedNode = null"
|
|
@change-trust="changeTrust"
|
|
@remove-node="removeNode"
|
|
@deploy-app="deployApp"
|
|
@dwn-sync="triggerDwnSync"
|
|
/>
|
|
|
|
<JoinModal
|
|
:visible="showJoinModal"
|
|
:joining="joining"
|
|
:error="joinError"
|
|
:success="joinSuccess"
|
|
@close="showJoinModal = false"
|
|
@join="joinFederation"
|
|
/>
|
|
|
|
<DiscoverModal
|
|
:visible="showDiscoverModal"
|
|
:outbound-sent="pendingRequests"
|
|
@close="showDiscoverModal = false"
|
|
@sent="loadPendingRequests"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { useTransportStore } from '@/stores/transport'
|
|
import { useAppStore } from '@/stores/app'
|
|
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
|
import FederationHeader from './federation/FederationHeader.vue'
|
|
import RotateDidModal from './federation/RotateDidModal.vue'
|
|
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()
|
|
const appStore = useAppStore()
|
|
|
|
const nodes = ref<FederatedNode[]>([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const selectedNode = ref<FederatedNode | null>(null)
|
|
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
|
|
|
const inviteCode = ref('')
|
|
const generatingInvite = ref(false)
|
|
|
|
const showJoinModal = ref(false)
|
|
const joining = ref(false)
|
|
const joinError = ref('')
|
|
const joinSuccess = ref(false)
|
|
|
|
const syncing = ref(false)
|
|
const syncResults = ref<SyncResult[]>([])
|
|
|
|
const deploying = ref(false)
|
|
const deployResult = ref('')
|
|
|
|
const viewTabs = [
|
|
{ id: 'list', label: 'List View' },
|
|
{ id: 'map', label: 'Network Map' },
|
|
] as const
|
|
|
|
type ViewId = typeof viewTabs[number]['id']
|
|
const activeView = ref<ViewId>(
|
|
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
|
|
)
|
|
|
|
function setView(id: ViewId) {
|
|
activeView.value = id
|
|
localStorage.setItem('federation-view', id)
|
|
}
|
|
|
|
const selfDid = ref('')
|
|
|
|
const mapNodes = computed(() => {
|
|
const result = []
|
|
if (selfDid.value) {
|
|
result.push({
|
|
did: selfDid.value,
|
|
label: appStore.serverName,
|
|
trust_level: 'trusted' as const,
|
|
online: true,
|
|
app_count: 0,
|
|
is_self: true,
|
|
})
|
|
}
|
|
for (const node of nodes.value) {
|
|
result.push({
|
|
did: node.did,
|
|
label: nodeName(node),
|
|
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
|
|
online: isOnlineCheck(node),
|
|
app_count: node.last_state?.apps?.length ?? 0,
|
|
is_self: false,
|
|
})
|
|
}
|
|
return result
|
|
})
|
|
|
|
const mapLinks = computed(() => {
|
|
if (!selfDid.value) return []
|
|
return nodes.value.map(n => ({
|
|
source: selfDid.value,
|
|
target: n.did,
|
|
}))
|
|
})
|
|
|
|
const dwnStatus = ref<DwnStatus | null>(null)
|
|
const dwnSyncing = ref(false)
|
|
|
|
const dwnSyncDotClass = computed(() => {
|
|
if (!dwnStatus.value) return 'bg-white/30'
|
|
switch (dwnStatus.value.sync_status) {
|
|
case 'synced': return 'bg-green-400'
|
|
case 'syncing': return 'bg-yellow-400 animate-pulse'
|
|
case 'error': return 'bg-red-400'
|
|
default: return 'bg-white/30'
|
|
}
|
|
})
|
|
|
|
const dwnSyncLabel = computed(() => {
|
|
if (!dwnStatus.value) return 'Unknown'
|
|
switch (dwnStatus.value.sync_status) {
|
|
case 'synced': return 'Synced'
|
|
case 'syncing': return 'Syncing...'
|
|
case 'error': return 'Error'
|
|
default: return dwnStatus.value.sync_status
|
|
}
|
|
})
|
|
|
|
// DID rotation
|
|
const showRotateModal = ref(false)
|
|
const rotatingDid = ref(false)
|
|
const rotateError = ref('')
|
|
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
|
|
}
|
|
}
|
|
|
|
async function cancelPending(id: string) {
|
|
pendingBusyId.value = id
|
|
discoveryError.value = ''
|
|
try {
|
|
// Default notify=true from the rpc-client — the peer's inbound row
|
|
// disappears from their UI so they don't have to wonder about a
|
|
// stale handshake.
|
|
await rpcClient.federationCancelRequest(id)
|
|
await loadPendingRequests()
|
|
} catch (e: unknown) {
|
|
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
|
|
} finally {
|
|
pendingBusyId.value = null
|
|
}
|
|
}
|
|
|
|
function isOnlineCheck(node: FederatedNode): boolean {
|
|
if (!node.last_seen) return false
|
|
const lastSeen = new Date(node.last_seen).getTime()
|
|
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
|
|
return lastSeen > tenMinutesAgo
|
|
}
|
|
|
|
async function loadNodes() {
|
|
return loadNodesWithOptions()
|
|
}
|
|
|
|
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
|
const showLoader = options.showLoader ?? nodes.value.length === 0
|
|
const surfaceErrors = options.surfaceErrors ?? true
|
|
try {
|
|
if (showLoader) loading.value = true
|
|
const result = await rpcClient.federationListNodes()
|
|
nodes.value = result.nodes
|
|
error.value = ''
|
|
} catch (e) {
|
|
if (surfaceErrors) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
|
}
|
|
} finally {
|
|
if (showLoader) loading.value = false
|
|
}
|
|
}
|
|
|
|
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
|
inviteType.value = type
|
|
generateInvite()
|
|
}
|
|
|
|
async function generateInvite() {
|
|
try {
|
|
generatingInvite.value = true
|
|
error.value = ''
|
|
const result = await rpcClient.federationInvite()
|
|
inviteCode.value = result.code
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
|
} finally {
|
|
generatingInvite.value = false
|
|
}
|
|
}
|
|
|
|
async function joinFederation(code: string) {
|
|
try {
|
|
joining.value = true
|
|
joinError.value = ''
|
|
joinSuccess.value = false
|
|
await rpcClient.federationJoin(code)
|
|
joinSuccess.value = true
|
|
await loadNodes()
|
|
setTimeout(() => { showJoinModal.value = false; joinSuccess.value = false }, 1500)
|
|
} catch (e) {
|
|
joinError.value = e instanceof Error ? e.message : 'Failed to join'
|
|
} finally {
|
|
joining.value = false
|
|
}
|
|
}
|
|
|
|
async function syncAll() {
|
|
try {
|
|
syncing.value = true
|
|
error.value = ''
|
|
syncResults.value = []
|
|
const result = await rpcClient.call<{
|
|
synced: number; failed: number;
|
|
results: SyncResult[]
|
|
}>({ method: 'federation.sync-state', timeout: 180000 })
|
|
syncResults.value = result.results
|
|
await loadNodes()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Sync failed — some peers may be unreachable over Tor'
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
async function changeTrust(did: string, level: string) {
|
|
try {
|
|
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
|
await loadNodes()
|
|
if (selectedNode.value?.did === did) {
|
|
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
|
}
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
|
}
|
|
}
|
|
|
|
async function removeNode(did: string) {
|
|
try {
|
|
await rpcClient.federationRemoveNode(did)
|
|
selectedNode.value = null
|
|
await loadNodes()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to remove node'
|
|
}
|
|
}
|
|
|
|
async function deployApp(did: string, appId: string) {
|
|
try {
|
|
deploying.value = true
|
|
deployResult.value = ''
|
|
await rpcClient.federationDeployApp({ did, appId })
|
|
deployResult.value = `Successfully deployed ${appId} to remote node`
|
|
} catch (e) {
|
|
deployResult.value = `Error: ${e instanceof Error ? e.message : 'Deploy failed'}`
|
|
} finally {
|
|
deploying.value = false
|
|
}
|
|
}
|
|
|
|
async function loadDwnStatus() {
|
|
try {
|
|
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
|
|
dwnStatus.value = result
|
|
} catch {
|
|
dwnStatus.value = null
|
|
}
|
|
}
|
|
|
|
async function triggerDwnSync() {
|
|
try {
|
|
dwnSyncing.value = true
|
|
await rpcClient.call({ method: 'dwn.sync', timeout: 120000 })
|
|
await loadDwnStatus()
|
|
} catch {
|
|
// Silently handle sync errors
|
|
} finally {
|
|
dwnSyncing.value = false
|
|
}
|
|
}
|
|
|
|
async function cleanupDeadNodes() {
|
|
cleaningNodes.value = true
|
|
try {
|
|
const deadNodes = nodes.value.filter(n => !isOnlineCheck(n) && (!n.last_seen || n.last_seen === 'never'))
|
|
for (const node of deadNodes) {
|
|
await rpcClient.federationRemoveNode(node.did)
|
|
}
|
|
await loadNodes()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Cleanup failed'
|
|
} finally {
|
|
cleaningNodes.value = false
|
|
}
|
|
}
|
|
|
|
async function rotateDid(password: string) {
|
|
if (!password) return
|
|
rotatingDid.value = true
|
|
rotateError.value = ''
|
|
rotateSuccess.value = ''
|
|
try {
|
|
const result = await rpcClient.call<{
|
|
old_did: string; new_did: string; proof_signature: string; proof_message: string
|
|
}>({ method: 'node.rotate-did', params: { password } })
|
|
|
|
selfDid.value = result.new_did
|
|
try { localStorage.setItem('neode_did', result.new_did) } catch { /* noop */ }
|
|
rotateSuccess.value = `DID rotated. Notifying peers...`
|
|
|
|
const notify = await rpcClient.call<{ notified: number; failed: number }>({
|
|
method: 'federation.notify-did-change',
|
|
params: {
|
|
old_did: result.old_did,
|
|
new_did: result.new_did,
|
|
proof_signature: result.proof_signature,
|
|
proof_message: result.proof_message,
|
|
},
|
|
timeout: 120000,
|
|
})
|
|
rotateSuccess.value = `DID rotated successfully. ${notify.notified} peers notified${notify.failed > 0 ? `, ${notify.failed} failed` : ''}.`
|
|
} catch (err: unknown) {
|
|
rotateError.value = err instanceof Error ? err.message : 'Rotation failed'
|
|
} finally {
|
|
rotatingDid.value = false
|
|
}
|
|
}
|
|
|
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
onMounted(async () => {
|
|
loadNodesWithOptions({ showLoader: true })
|
|
loadDwnStatus()
|
|
loadDiscoveryState()
|
|
loadPendingRequests()
|
|
transportStore.fetchPeers()
|
|
try {
|
|
const result = await rpcClient.getNodeDid()
|
|
selfDid.value = result.did
|
|
} catch {
|
|
// Self DID not available
|
|
}
|
|
autoRefreshTimer = setInterval(() => {
|
|
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
|
loadPendingRequests()
|
|
}, 5000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (autoRefreshTimer) {
|
|
clearInterval(autoRefreshTimer)
|
|
autoRefreshTimer = null
|
|
}
|
|
})
|
|
</script>
|