Files
archy/neode-ui/src/views/Federation.vue
T
archipelagoandClaude Fable 5 6193a009de
Demo images / Build & push demo images (push) Successful in 3m35s
fix(federation-ui): show trust-change failures inside the modal, not behind it
changeTrust() wrote failures into `error`, which renders inside <NodeList>
— page content that sits BEHIND the open NodeDetailModal. So when
promoting a peer to Trusted failed, the operator saw the dropdown snap
back and nothing else: the error banner was painted on a surface the
modal was covering. Reported as "the error doesn't show in the modal",
2026-08-09.

NodeDetailModal gains an actionError prop rendered directly under the
Trust Level control, and Federation.vue routes failures there whenever
that node's modal is open (clearing on close and on the next attempt).
The page banner still serves failures with no modal in the way, and the
password-retry path keeps its own in-modal error as before.

Verified: vue-tsc clean; federation component tests 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 06:50:05 -04:00

797 lines
28 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 (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
<button
v-for="tab in viewTabs"
:key="tab.id"
class="mode-switcher-btn"
role="tab"
:aria-selected="activeView === tab.id"
:class="{ 'mode-switcher-btn-active': activeView === tab.id }"
@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">
<div class="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>
<button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@click="showSigningInfo = !showSigningInfo"
>
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
</button>
</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>
<!-- Signing details: shows WHO signs the presence event (always the
node's own discovery key — deliberately not pickable, so a user's
personal Web5/Nostr identities can never be burned for node
discovery) and a human-readable view of WHAT gets signed. -->
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
node discovery always signs with the node's own key, never a
personal identity, so there is nothing to pick. -->
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
</div>
<div class="mt-0.5">
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading' }}</span>
</div>
</div>
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Presence events are always signed with this node's dedicated discovery key.
Your personal identities are never used for node discovery, so unlike the app
sign-in overlay there is nothing to choose here.
</p>
</div>
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed &amp; published</label>
<dl class="text-xs space-y-1.5">
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-2">
Every presence event carries a Schnorr signature (NIP-01) made with the key
above relays reject unsigned events, so anything you see via discovery was
cryptographically signed by its node. Your onion address is never part of this
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
</p>
</div>
</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"
:action-error="nodeActionError"
@close="selectedNode = null; nodeActionError = ''"
@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"
/>
<PresenceSignModal
:show="showPresenceSignModal"
:server-name="appStore.serverName"
:npub="nodeNpub"
:did="selfDid"
:version="appVersion"
:busy="discoveryToggling"
@confirm="confirmPresenceSign"
@cancel="showPresenceSignModal = false"
/>
<TrustPasswordModal
:visible="showTrustPassword"
:context="trustPasswordContext"
:busy="trustPasswordBusy"
:error="trustPasswordError"
@confirm="submitTrustPassword"
@close="closeTrustPassword"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
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 PresenceSignModal from './federation/PresenceSignModal.vue'
import TrustPasswordModal from './federation/TrustPasswordModal.vue'
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
import type { PendingPeerRequest } from '@/api/rpc-client'
import { nodeName, nodeNameFromDid, timeAgo } from './federation/utils'
const transportStore = useTransportStore()
const appStore = useAppStore()
const syncStore = useSyncStore()
// Cached: revisits paint the node list instantly; the 5s poll and mutation
// refreshes revalidate behind it. `loading` is initial-load only (background
// refreshes keep content on screen — the old showLoader:false semantics).
const nodesRes = useCachedResource<FederatedNode[]>({
key: 'federation.nodes',
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
persist: false, // FederatedNode carries did/pubkey/onion — peer identity payload (T-02-01)
})
const nodes = computed(() => nodesRes.data.value ?? [])
const loading = computed(() => nodesRes.loadState.value === 'loading')
const error = ref('')
/** Failure from an action taken inside NodeDetailModal (trust dropdown). Kept
* separate from `error`, which renders in NodeList behind the modal. */
const nodeActionError = 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 dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
persist: true, // sync status/counters only — no identity payload
immediate: false,
})
const dwnStatus = computed(() => dwnStatusRes.data.value)
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 || 'Unknown'
}
})
// 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)
// Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false)
const showPresenceSignModal = ref(false)
const nodeNpub = ref('')
const appVersion = computed(() => syncStore.serverInfo?.version || '')
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
async function loadNodeNpub() {
try {
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
} catch {
// Nostr key not provisioned yet — panel shows a placeholder
}
}
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() {
if (!discoveryEnabled.value) {
// Enabling publishes a signed presence event — surface the signer overlay
// first so the user sees exactly what gets signed and with which key.
// Nothing is published until they confirm.
if (!nodeNpub.value) loadNodeNpub()
showPresenceSignModal.value = true
return
}
// Disabling publishes nothing new — apply immediately.
discoveryToggling.value = true
discoveryError.value = ''
try {
const result = await rpcClient.nostrSetDiscovery(false)
discoveryEnabled.value = !!result.enabled
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
} finally {
discoveryToggling.value = false
}
}
async function confirmPresenceSign() {
discoveryToggling.value = true
discoveryError.value = ''
try {
const result = await rpcClient.nostrSetDiscovery(true)
discoveryEnabled.value = !!result.enabled
showPresenceSignModal.value = false
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to enable 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
}
/** Explicit reload (mutations, retry): surfaces a load failure in the error
* banner. The background poll calls nodesRes.refresh() directly and stays
* silent, like the old surfaceErrors:false path. */
async function loadNodes() {
await nodesRes.refresh()
if (nodesRes.error.value) error.value = nodesRes.error.value
else error.value = ''
}
function handleGenerateInvite(type: 'trusted' | 'observer') {
inviteType.value = type
generateInvite()
}
/** The backend is the only authority on whether a given change is an
* escalation, so the UI never pre-judges: it attempts the call and prompts
* only when the backend says a password is required. That keeps demotions —
* and no-op re-sets of an already-Trusted peer — free of a pointless prompt
* without the frontend having to duplicate the rule. */
function isPasswordRequired(e: unknown): boolean {
return e instanceof Error && e.message.includes('PASSWORD_REQUIRED')
}
const showTrustPassword = ref(false)
const trustPasswordContext = ref('')
const trustPasswordBusy = ref(false)
const trustPasswordError = ref('')
let pendingTrustAction: ((password: string) => Promise<void>) | null = null
function promptForTrustPassword(context: string, action: (password: string) => Promise<void>) {
trustPasswordContext.value = context
trustPasswordError.value = ''
pendingTrustAction = action
showTrustPassword.value = true
}
function closeTrustPassword() {
showTrustPassword.value = false
trustPasswordError.value = ''
trustPasswordBusy.value = false
pendingTrustAction = null
}
async function submitTrustPassword(password: string) {
if (!pendingTrustAction) return
try {
trustPasswordBusy.value = true
trustPasswordError.value = ''
await pendingTrustAction(password)
closeTrustPassword()
} catch (e) {
// Keep the failure inside the modal so the operator can retry in place
// rather than losing the pending action to the page-level banner.
trustPasswordError.value = e instanceof Error ? e.message : 'Password verification failed'
} finally {
trustPasswordBusy.value = false
}
}
/** Raw call — throws so both the first attempt and the password retry can
* route the error to the right place. */
async function requestInvite(password?: string) {
// The invite type is not cosmetic: it sets the trust level the invite
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
const result = await rpcClient.federationInvite(inviteType.value, password)
inviteCode.value = result.code
}
async function generateInvite() {
try {
generatingInvite.value = true
error.value = ''
await requestInvite()
} catch (e) {
if (isPasswordRequired(e)) {
promptForTrustPassword(
'This invite grants Trusted access to whoever redeems it — full read of this node\'s state, and the ability to deploy apps to it. Confirm with your node password.',
requestInvite,
)
return
}
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
}
}
/** Raw call — throws; see `requestInvite`. */
async function requestTrustChange(did: string, level: string, password?: string) {
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted', password)
await loadNodes()
if (selectedNode.value?.did === did) {
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
}
}
async function changeTrust(did: string, level: string) {
nodeActionError.value = ''
try {
await requestTrustChange(did, level)
} catch (e) {
if (isPasswordRequired(e)) {
const name = nodeNameFromDid(did, nodes.value)
promptForTrustPassword(
`Granting ${name} Trusted lets it read this node's state and deploy apps to it. Confirm with your node password.`,
(password) => requestTrustChange(did, level, password),
)
return
}
// Show the failure INSIDE the open modal. `error` renders in NodeList,
// which sits behind NodeDetailModal — so routing it there made a failed
// trust change look like the dropdown simply doing nothing.
const msg = e instanceof Error ? e.message : 'Failed to update trust level'
if (selectedNode.value?.did === did) nodeActionError.value = msg
else error.value = msg
}
}
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
}
}
function loadDwnStatus() {
return dwnStatusRes.refresh()
}
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 () => {
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
loadNodeNpub()
transportStore.fetchPeers()
try {
const result = await rpcClient.getNodeDid()
selfDid.value = result.did
} catch {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
void nodesRes.refresh()
loadPendingRequests()
}, 5000)
})
onUnmounted(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
</script>