Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,39 @@
<template>
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
Federation.vue (not in the header) and is hidden by the parent on the
Network Map tab, where vertical space belongs to the map. -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
const props = defineProps<{
selfDid: string
serverName: string
}>()
defineEmits<{
rotate: []
}>()
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
function handleCopy() {
if (props.selfDid) {
safeClipboardWrite(props.selfDid)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
}
</script>
@@ -0,0 +1,221 @@
<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-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching relays...
</div>
<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>
<PeerRequestModal
:show="requestTarget !== null"
:target-label="requestTarget?.label ?? ''"
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest"
@cancel="requestTarget = null"
/>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
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
}
}
// Request confirmation modal: peer requests always offer an optional
// message before anything is sent (Request/Cancel).
const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null)
function sendTo(node: DiscoverableNode) {
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
}
function sendDirect() {
const v = manualNpub.value.trim()
if (!v) return
requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}${v.slice(-6)}` : v, clearManual: true }
}
async function confirmRequest(message: string | undefined) {
const req = requestTarget.value
if (!req) return
await sendInternal(req.target, message)
if (req.clearManual) manualNpub.value = ''
requestTarget.value = null
}
async function sendInternal(target: string, message?: string) {
sendingTo.value = target
error.value = ''
try {
await rpcClient.handshakeConnect(target, message)
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)}`
}
defineExpose({ refresh })
</script>
@@ -0,0 +1,54 @@
<template>
<div class="mb-6">
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
<p class="text-white/70">Connect, sync, and share with trusted nodes</p>
</div>
<!-- Your Node DID top right card (desktop) -->
<div v-if="selfDid" class="hidden md:block shrink-0">
<div class="glass-card px-4 py-3 flex items-center gap-3">
<div class="min-w-0">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</div>
</div>
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
Federation.vue below the view tabs (hidden on the map tab). -->
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
const props = defineProps<{
selfDid: string
serverName: string
}>()
defineEmits<{
rotate: []
}>()
const router = useRouter()
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
function handleCopy() {
if (props.selfDid) {
safeClipboardWrite(props.selfDid)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
}
</script>
@@ -0,0 +1,74 @@
<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-md w-full relative z-10">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Join Federation</h2>
<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>
<p class="text-sm text-white/60 mb-4">Paste the invite code from the node you want to federate with.</p>
<textarea
v-model="joinCode"
placeholder="fed1:..."
rows="3"
class="w-full bg-black/30 text-white text-sm rounded-lg p-3 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono resize-none"
></textarea>
<div v-if="error" class="mt-3 text-sm text-red-400">{{ error }}</div>
<div v-if="success" class="mt-3 text-sm text-green-400">Successfully joined federation</div>
<div class="flex gap-3 mt-4">
<button
@click="$emit('close')"
class="flex-1 px-4 py-2 glass-button rounded text-sm text-white/70"
>Cancel</button>
<button
@click="handleJoin"
class="flex-1 px-4 py-2 glass-button rounded text-sm text-white font-medium disabled:opacity-50"
:disabled="joining || !joinCode.trim()"
>
{{ joining ? 'Joining...' : 'Join' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
visible: boolean
joining: boolean
error: string
success: boolean
}>()
const emit = defineEmits<{
close: []
join: [code: string]
}>()
const joinCode = ref('')
function handleJoin() {
if (joinCode.value.trim()) {
emit('join', joinCode.value.trim())
}
}
// Clear code on successful join
watch(() => props.success, (val) => {
if (val) joinCode.value = ''
})
</script>
@@ -0,0 +1,206 @@
<template>
<Teleport to="body">
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Node Details</h2>
<button @click="handleClose" 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="space-y-4">
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">DID</p>
<p class="text-sm text-white/80 font-mono break-all">{{ node.did }}</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Onion Address</p>
<p v-if="node.trust_level === 'trusted'" class="text-sm text-white/80 font-mono break-all">{{ node.onion }}</p>
<p v-else class="text-sm text-white/30 italic">Not visible to peers</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Trust Level</p>
<div class="flex items-center gap-2 mt-1">
<select
:value="node.trust_level"
@change="onTrustChange"
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
>
<option value="trusted">Trusted</option>
<option value="observer">Observer</option>
<option value="untrusted">Blocked</option>
</select>
</div>
<p class="text-xs text-white/40 mt-2">
<span class="text-white/30">Granted via:</span> {{ trustSourceLabel }}
</p>
<p v-if="actionError" class="text-xs text-red-400 mt-2" role="alert">{{ actionError }}</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Added</p>
<p class="text-sm text-white/80">{{ node.added_at }}</p>
</div>
<div v-if="node.trust_level === 'trusted' && node.last_state" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Resource Usage</p>
<div class="grid grid-cols-2 gap-2 text-sm text-white/70">
<div>CPU: {{ node.last_state.cpu_usage_percent?.toFixed(1) ?? '--' }}%</div>
<div>Uptime: {{ node.last_state.uptime_secs ? formatUptime(node.last_state.uptime_secs) : '--' }}</div>
<div>RAM: {{ formatBytes(node.last_state.mem_used_bytes) }} / {{ formatBytes(node.last_state.mem_total_bytes) }}</div>
<div>Disk: {{ formatBytes(node.last_state.disk_used_bytes) }} / {{ formatBytes(node.last_state.disk_total_bytes) }}</div>
</div>
</div>
<div v-if="node.last_state?.apps?.length && node.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Apps ({{ node.last_state.apps.length }})</p>
<div class="space-y-1">
<div v-for="app in node.last_state.apps" :key="app.id" class="flex items-center justify-between text-sm">
<span class="text-white/80">{{ app.id }}</span>
<span class="text-xs" :class="app.status === 'running' ? 'text-green-400' : 'text-white/40'">{{ app.status }}</span>
</div>
</div>
</div>
<!-- Deploy App (trusted only) -->
<div v-if="node.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Deploy App</p>
<div class="flex gap-2">
<input
v-model="deployAppId"
placeholder="App ID (e.g. bitcoin)"
class="flex-1 bg-black/30 text-white text-sm rounded px-2 py-1.5 border border-white/10 focus:border-orange-400/50 focus:outline-none"
/>
<button
@click="handleDeploy"
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
:disabled="deploying || !deployAppId.trim()"
>
{{ deploying ? 'Deploying...' : 'Deploy' }}
</button>
</div>
<p v-if="deployResult" class="text-xs mt-2" :class="deployResult.startsWith('Error') ? 'text-red-400' : 'text-green-400'">{{ deployResult }}</p>
</div>
<!-- DWN Sync -->
<div class="bg-white/5 rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/40">DWN Sync</p>
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full" :class="dwnSyncDotClass"></span>
<span class="text-xs text-white/50">{{ dwnSyncLabel }}</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-white/70 mb-3">
<div><span class="text-white/30">Messages:</span> {{ dwnMessageCount }}</div>
<div><span class="text-white/30">Last sync:</span> {{ dwnLastSync }}</div>
</div>
<button
@click="emit('dwn-sync')"
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
:disabled="dwnSyncing"
>
{{ dwnSyncing ? 'Syncing...' : 'Sync Now' }}
</button>
</div>
<div v-if="!confirmRemove">
<button
@click="confirmRemove = true"
class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
>
Remove from Federation
</button>
</div>
<div v-else class="mt-4 p-3 bg-red-400/10 rounded-lg border border-red-400/20">
<p class="text-sm text-red-400 mb-3">Are you sure? This node will be removed from your federation.</p>
<div class="flex gap-3">
<button
@click="confirmRemove = false"
class="flex-1 px-3 py-1.5 glass-button rounded text-sm text-white/70"
>Cancel</button>
<button
@click="emit('remove-node', node!.did)"
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
>Confirm Remove</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FederatedNode } from './types'
import { formatBytes, formatUptime } from './utils'
const props = defineProps<{
node: FederatedNode | null
dwnSyncDotClass: string
dwnSyncLabel: string
dwnMessageCount: string
dwnLastSync: string
dwnSyncing: boolean
deploying: boolean
deployResult: string
/** Failure from an action taken INSIDE this modal (e.g. the trust dropdown).
* Federation.vue used to route these to the page-level banner in NodeList,
* which sits behind this modal — so a failed trust change looked like
* nothing happening at all. */
actionError?: string
}>()
const emit = defineEmits<{
close: []
'change-trust': [did: string, level: string]
'remove-node': [did: string]
'deploy-app': [did: string, appId: string]
'dwn-sync': []
}>()
const confirmRemove = ref(false)
const deployAppId = ref('')
const TRUST_SOURCE_LABELS: Record<string, string> = {
invite: 'An invite you minted',
'uninvited-join': 'Joined without an invite — capped at Observer',
'transitive-merge': 'Advertised by another peer — capped at Observer',
manual: 'You set it here',
}
/** Unknown provenance is stated plainly rather than hidden: a peer recorded
* before this was tracked is precisely the one worth a second look. */
const trustSourceLabel = computed(
() => TRUST_SOURCE_LABELS[props.node?.trust_source ?? ''] ?? 'Unknown — recorded before this was tracked',
)
/** Snap the select back to the node's actual level immediately. Promoting to
* Trusted asks for the node password, and the operator may cancel or get it
* wrong — without this the dropdown would keep displaying a level the node
* never accepted. On success the parent reloads and the prop drives the new
* value back in. */
function onTrustChange(event: Event) {
const select = event.target as HTMLSelectElement
const level = select.value
if (!props.node) return
select.value = props.node.trust_level
emit('change-trust', props.node.did, level)
}
function handleClose() {
confirmRemove.value = false
deployAppId.value = ''
emit('close')
}
function handleDeploy() {
if (props.node && deployAppId.value.trim()) {
emit('deploy-app', props.node.did, deployAppId.value.trim())
deployAppId.value = ''
}
}
</script>
+226
View File
@@ -0,0 +1,226 @@
<template>
<div>
<!-- Sync Results -->
<div v-if="syncResults.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Sync Results</h2>
<button @click="$emit('clear-sync-results')" class="text-white/40 hover:text-white/70 transition-colors text-sm">Dismiss</button>
</div>
<div class="space-y-2">
<div v-for="r in syncResults" :key="r.did" class="flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<div class="w-2 h-2 rounded-full shrink-0" :class="r.status === 'ok' ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white/80 truncate" :title="r.did">{{ nodeNameFromDid(r.did, nodes) }}</span>
<span v-if="r.status === 'ok'" class="text-xs text-green-400">{{ r.apps }} apps</span>
<span v-else class="text-xs text-red-400 truncate">{{ r.error }}</span>
</div>
</div>
</div>
<!-- Error Display -->
<div v-if="error" class="glass-card p-4 mb-6 border-red-400/30">
<p class="text-sm text-red-400">{{ error }}</p>
</div>
<!-- Two-column: Your Nodes + Peers -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Your Nodes (Trusted) -->
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
<div v-if="loading && nodes.length === 0" class="flex items-center gap-3 py-8 justify-center">
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
<span class="text-white/60 text-sm">Loading nodes...</span>
</div>
<div v-else-if="nodes.length === 0" class="text-center py-12">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<p class="text-white/50 text-sm mb-2">No federated nodes yet</p>
<p class="text-white/30 text-xs">Generate an invite code or join an existing federation</p>
</div>
<div v-else class="space-y-3 overflow-y-auto">
<div
v-for="node in trustedNodes"
:key="node.did"
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
@click="$emit('select-node', node)"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white truncate" :title="node.did">{{ nodeName(node) }}</span>
<span
v-if="transportBadge(node)"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
:class="transportBadge(node)!.cls"
:title="transportBadge(node)!.title"
>{{ transportBadge(node)!.label }}</span>
<span
v-if="syncErrorBadge(node)"
data-testid="sync-error-badge"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 truncate bg-red-500/20 text-red-300 ring-1 ring-red-400/40"
:title="syncErrorBadge(node)!.title"
>SYNC</span>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full shrink-0"
:class="trustBadgeClass(node.trust_level)"
>{{ node.trust_level }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-white/50">
<div>
<span class="text-white/30">Apps:</span>
{{ node.last_state?.apps?.length ?? '--' }}
</div>
<div>
<span class="text-white/30">CPU:</span>
{{ node.last_state?.cpu_usage_percent != null ? node.last_state.cpu_usage_percent.toFixed(1) + '%' : '--' }}
</div>
<div class="flex items-center gap-1">
<span class="text-white/30">DWN:</span>
<span class="w-1.5 h-1.5 rounded-full" :class="dwnSyncDotClass"></span>
</div>
<div>
<span class="text-white/30">Seen:</span>
{{ node.last_seen ? timeAgo(node.last_seen) : 'never' }}
</div>
</div>
</div>
</div>
</div>
<!-- Peers (Observer level) -->
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Peers <span v-if="peerNodes.length > 0" class="text-sm font-normal text-white/50">({{ peerNodes.length }})</span></h2>
<button
v-if="nodes.some(n => !isOnline(n) && n.last_seen === 'never')"
@click="$emit('cleanup-dead')"
:disabled="cleaningNodes"
class="glass-button px-3 py-1.5 rounded-lg text-xs text-red-300"
>
{{ cleaningNodes ? 'Removing...' : 'Remove Dead Nodes' }}
</button>
</div>
<div v-if="peerNodes.length === 0" class="text-center py-6">
<p class="text-white/50 text-sm">No peers yet</p>
<p class="text-white/30 text-xs mt-1">Invite a peer to share public content</p>
</div>
<div v-else class="space-y-3 overflow-y-auto">
<div
v-for="node in peerNodes"
:key="node.did"
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
@click="$emit('select-node', node)"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white truncate" :title="node.did">{{ nodeName(node) }}</span>
<span
v-if="transportBadge(node)"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
:class="transportBadge(node)!.cls"
:title="transportBadge(node)!.title"
>{{ transportBadge(node)!.label }}</span>
<span
v-if="syncErrorBadge(node)"
data-testid="sync-error-badge"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 truncate bg-red-500/20 text-red-300 ring-1 ring-red-400/40"
:title="syncErrorBadge(node)!.title"
>SYNC</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
</div>
<div class="text-xs text-white/40">
<span>Seen: {{ node.last_seen ? formatTimeAgo(node.last_seen) : 'never' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FederatedNode, SyncResult } from './types'
import { nodeName, nodeNameFromDid, timeAgo, formatTimeAgo, trustBadgeClass, isOnline } from './utils'
const props = defineProps<{
nodes: FederatedNode[]
loading: boolean
error: string
syncResults: SyncResult[]
dwnSyncDotClass: string
cleaningNodes: boolean
}>()
defineEmits<{
'select-node': [node: FederatedNode]
'clear-sync-results': []
'cleanup-dead': []
}>()
const trustedNodes = computed(() => props.nodes.filter(n => n.trust_level === 'trusted'))
const peerNodes = computed(() => props.nodes.filter(n => n.trust_level !== 'trusted'))
// Badge showing the actual transport the most recent reach used —
// NOT a prediction. If we've never reached the peer, return null so
// the badge stays hidden rather than lying. When the transport is
// fips, the tooltip also shows how recent the reading is so stale
// data is visible at a glance.
function transportBadge(node: FederatedNode): { label: string; cls: string; title: string } | null {
if (!node.last_transport) return null
const age = node.last_transport_at ? timeAgo(node.last_transport_at) : 'unknown'
switch (node.last_transport) {
case 'fips':
return {
label: 'FIPS',
cls: 'bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-400/40',
title: `Last reached via FIPS mesh · ${age}`,
}
case 'tor':
return {
label: 'TOR',
cls: 'bg-purple-500/20 text-purple-300 ring-1 ring-purple-400/40',
title: `Last reached via Tor · ${age}`,
}
case 'lan':
return {
label: 'LAN',
cls: 'bg-green-500/20 text-green-300 ring-1 ring-green-400/40',
title: `Last reached via LAN · ${age}`,
}
case 'mesh':
return {
label: 'MESH',
cls: 'bg-orange-500/20 text-orange-300 ring-1 ring-orange-400/40',
title: `Last reached via mesh radio · ${age}`,
}
default:
return null
}
}
// FED-02: the most recent sync attempt with this peer failed. Before this,
// the failure existed only as a `debug!` line on the node, so a peer that
// hadn't synced in days looked identical on this screen to one that synced a
// minute ago. Returns null when the peer's last attempt succeeded — the
// backend clears last_sync_error on success, so the badge disappears on
// recovery rather than sticking around.
//
// The row must stay single-line, so the badge itself is a fixed short label
// and the daemon's message plus when it happened go in the tooltip (the same
// truncate + :title treatment the node-name span uses).
function syncErrorBadge(node: FederatedNode): { title: string } | null {
if (!node.last_sync_error) return null
const age = node.last_sync_error_at ? timeAgo(node.last_sync_error_at) : 'unknown'
return { title: `Last sync failed · ${age} · ${node.last_sync_error}` }
}
</script>
@@ -0,0 +1,139 @@
<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 v-else-if="req.outbound && req.state === 'sent'" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-white/70 hover:text-red-300 disabled:opacity-50"
:disabled="busyId === req.id"
:title="'Withdraw the request and notify the peer to drop their pending row'"
@click="$emit('cancel', req.id)"
>
{{ busyId === req.id ? '' : 'Cancel' }}
</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]
cancel: [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>
@@ -0,0 +1,264 @@
<template>
<Teleport to="body">
<Transition name="presence-sign">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop frosted blur, same treatment as the app signer overlay -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
aria-label="Sign and publish node presence"
class="relative z-10 w-full max-w-lg flex flex-col min-h-0 max-h-full"
>
<!-- Header: glass disc + radial viz ring, mirroring NostrIdentityPicker -->
<div class="relative mb-4 sm:mb-6 flex flex-col items-center shrink-0">
<div class="presence-hero">
<div class="presence-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="presence-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<div class="presence-glass-border">
<div class="presence-glass-inner">
<svg class="w-9 h-9 text-white/90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Sign &amp; Publish Presence</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr node discovery</p>
</div>
<!-- Scrollable content: on short viewports (small phones) this card
shrinks and scrolls internally so the action buttons stay in view -->
<div class="glass-card p-4 space-y-4 overflow-y-auto min-h-0 overscroll-contain">
<!-- Signer: single locked row, same look as the identity picker rows.
Node discovery always signs with the node's own key — never a
personal identity — so there is deliberately nothing to pick. -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">Signer</div>
<div class="w-full p-3 rounded-lg bg-white/10 ring-1 ring-white/20">
<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">{{ (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">{{ 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">{{ npub ? truncateNpub(npub) : '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.5">
Presence events are always signed with this node's dedicated discovery key
your personal identities are never used, so there is nothing to choose.
</p>
</div>
<!-- Human-readable view of the exact content that gets signed -->
<div>
<div class="text-[10px] uppercase tracking-wide text-white/40 mb-1.5">What gets signed &amp; published</div>
<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">{{ did || '—' }}</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">{{ npub || '—' }}</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">{{ version || '—' }}</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. 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>
<!-- Actions -->
<div class="flex gap-3 mt-4 shrink-0">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@click="$emit('confirm')"
:disabled="busy"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 bg-white/10 text-white hover:bg-white/15 disabled:opacity-30 disabled:cursor-not-allowed"
>
{{ busy ? 'Publishing…' : 'Sign & Publish' }}
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest shrink-0">
NIP-01 &middot; SECP256K1 &middot; SCHNORR &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{
show: boolean
serverName: string
npub: string
did: string
version: string
busy?: boolean
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
</script>
<style scoped>
/* Hero container — same dimensions/pattern as the NostrIdentityPicker overlay */
.presence-hero {
position: relative;
width: 148px;
height: 148px;
}
/* Small phones: shrink the hero so the signed-info card keeps most of the
viewport; the card itself scrolls internally when it still doesn't fit. */
@media (max-width: 480px), (max-height: 740px) {
.presence-hero {
width: 96px;
height: 96px;
transform: scale(0.9);
}
.presence-viz-segment {
--seg-radius: -40px;
height: 10px;
}
.presence-glass-border {
width: 72px;
height: 72px;
}
}
.presence-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.presence-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px));
animation: presence-seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
@keyframes presence-seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(var(--seg-radius, -60px)) scaleY(0.4); }
}
.presence-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.presence-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal transitions — same curve as the identity picker */
.presence-sign-enter-active,
.presence-sign-leave-active {
transition: opacity 0.4s ease;
}
.presence-sign-enter-active > .relative {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.presence-sign-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.presence-sign-enter-from { opacity: 0; }
.presence-sign-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.presence-sign-leave-to { opacity: 0; }
.presence-sign-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
@@ -0,0 +1,151 @@
<template>
<div>
<!-- Quick Actions -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Link Your Nodes (Trusted) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Link Your Nodes</p>
<p class="text-xs text-white/60">Full trust, sync everything</p>
</div>
</div>
<button
@click="$emit('generate-invite', 'trusted')"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
</button>
</div>
<!-- Invite a Peer (Observer) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Invite a Peer</p>
<p class="text-xs text-white/60">Share public content</p>
</div>
</div>
<button
@click="$emit('generate-invite', 'observer')"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite"
>
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
</button>
</div>
<!-- Join (accept code) -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Join</p>
<p class="text-xs text-white/60">Accept an invite code</p>
</div>
</div>
<button
@click="$emit('show-join')"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Enter Code
</button>
</div>
<!-- Sync State -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Sync</p>
<p class="text-xs text-white/60">Refresh all node states</p>
</div>
</div>
<button
@click="$emit('sync')"
class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="syncing"
>
{{ syncing ? 'Syncing...' : 'Sync Now' }}
</button>
</div>
</div>
</div>
<!-- Invite Code Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="inviteCode" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="$emit('clear-invite')">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">{{ inviteType === 'trusted' ? 'Link Your Nodes — Invite Code' : 'Peer Invite Code' }}</h2>
<button @click="$emit('clear-invite')" 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>
<p class="text-xs text-white/60 mb-3">Share this code with the node you want to federate with. It can only be used once.</p>
<div class="bg-black/30 rounded-lg p-4 font-mono text-xs text-orange-300 break-all select-all">{{ inviteCode }}</div>
<button
@click="handleCopyInvite"
class="mt-3 px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white transition-colors"
>
{{ copiedInvite ? 'Copied' : 'Copy to Clipboard' }}
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
generatingInvite: boolean
inviteType: 'trusted' | 'observer'
inviteCode: string
syncing: boolean
}>()
defineEmits<{
'generate-invite': [type: 'trusted' | 'observer']
'show-join': []
'sync': []
'clear-invite': []
}>()
const copiedInvite = ref(false)
async function handleCopyInvite() {
try {
await window.navigator.clipboard.writeText(props.inviteCode)
} catch {
const ta = document.createElement('textarea')
ta.value = props.inviteCode
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedInvite.value = true
setTimeout(() => { copiedInvite.value = false }, 2000)
}
</script>
@@ -0,0 +1,50 @@
<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-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-2">Rotate Node DID</h3>
<p class="text-sm text-white/60 mb-4">This generates a new identity keypair and notifies all federated peers. Your old DID will no longer be valid.</p>
<input v-model="password" type="password" placeholder="Enter your password to confirm" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4" />
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
<p v-if="success" class="text-green-400 text-xs mb-3">{{ success }}</p>
<div class="flex gap-3">
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="$emit('rotate', password)" :disabled="rotating || !password" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
{{ rotating ? 'Rotating...' : 'Rotate & Notify Peers' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
visible: boolean
rotating: boolean
error: string
success: string
}>()
const emit = defineEmits<{
close: []
rotate: [password: string]
}>()
const password = ref('')
function handleClose() {
password.value = ''
emit('close')
}
// Reset password when modal opens/closes
watch(() => props.visible, (val) => {
if (!val) password.value = ''
})
</script>
@@ -0,0 +1,74 @@
<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="handleClose">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-2">Confirm Trusted Access</h3>
<p class="text-sm text-white/60 mb-4">{{ context }}</p>
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="Enter your node password to confirm"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
@keyup.enter="submit"
/>
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
<div class="flex gap-3">
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="submit"
:disabled="busy || !password"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
>
{{ busy ? 'Verifying…' : 'Grant Trusted' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
const props = defineProps<{
visible: boolean
/** What is about to be granted, in the operator's terms. */
context: string
busy: boolean
error: string
}>()
const emit = defineEmits<{
close: []
confirm: [password: string]
}>()
const password = ref('')
const passwordInput = ref<HTMLInputElement | null>(null)
function submit() {
if (!password.value || props.busy) return
emit('confirm', password.value)
}
function handleClose() {
password.value = ''
emit('close')
}
// Never leave the password sitting in memory once the modal is dismissed,
// and put the cursor where the operator has to type anyway.
watch(() => props.visible, async (val) => {
if (!val) {
password.value = ''
return
}
await nextTick()
passwordInput.value?.focus()
})
</script>
@@ -0,0 +1,69 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import DiscoverModal from '../DiscoverModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
handshakeDiscover: vi.fn(),
handshakeConnect: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeNode() {
return {
nostr_pubkey: 'pubkey-one',
nostr_npub: 'npub1abcdefghijklmnopqrstuvwxyz',
did: 'did:key:node',
version: '1.8-alpha',
}
}
describe('DiscoverModal', () => {
it('keeps discoverable nodes visible while relay refresh is pending or fails', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
const wrapper = mount(DiscoverModal, {
props: { visible: false, outboundSent: [] },
global: {
stubs: {
Teleport: true,
},
},
})
await wrapper.setProps({ visible: true })
await flushPromises()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('version 1.8-alpha')
const pending = deferred<{ nodes: [] }>()
vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { refresh: () => Promise<void> }).refresh()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('Searching relays...')
expect(wrapper.text()).not.toContain('No discoverable nodes found')
pending.reject(new Error('relay offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('relay offline')
expect(wrapper.text()).not.toContain('Searching relays...')
expect(wrapper.text()).not.toContain('No discoverable nodes found')
})
})
@@ -0,0 +1,103 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NodeList from '../NodeList.vue'
import type { FederatedNode } from '../types'
const trustedNode: FederatedNode = {
did: 'did:key:z6MkTrustedNode',
pubkey: 'trusted-pubkey',
onion: 'trusted.onion',
trust_level: 'trusted',
added_at: '2026-06-10T00:00:00Z',
name: 'Trusted Node',
last_seen: '2026-06-10T00:00:00Z',
}
describe('NodeList', () => {
it('keeps existing nodes visible during background refresh loading', () => {
const wrapper = mount(NodeList, {
props: {
nodes: [trustedNode],
loading: true,
error: '',
syncResults: [],
dwnSyncDotClass: 'bg-green-400',
cleaningNodes: false,
},
})
expect(wrapper.text()).toContain('Trusted Node')
expect(wrapper.text()).not.toContain('Loading nodes...')
})
// FED-02: a sync failure used to exist only as a debug! log line on the
// node, so a peer that hadn't synced in days looked identical to one that
// synced a minute ago. It must now be visible on the node's own row.
it('shows a sync-error badge on a node whose last sync failed', () => {
const failing: FederatedNode = {
...trustedNode,
last_sync_error: 'Failed to reach federated peer',
last_sync_error_at: '2026-06-11T00:00:00Z',
}
const wrapper = mount(NodeList, {
props: {
nodes: [failing],
loading: false,
error: '',
syncResults: [],
dwnSyncDotClass: 'bg-green-400',
cleaningNodes: false,
},
})
const badge = wrapper.find('[data-testid="sync-error-badge"]')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('SYNC')
// The full message + when it happened live in the tooltip, so the row
// stays single-line (the node-name truncate idiom).
expect(badge.attributes('title')).toContain('Failed to reach federated peer')
})
// The guard against a badge that always renders: a healthy peer (and a
// peer that has recovered, since record_sync_result clears the field) must
// show no sync-error badge at all.
it('renders no sync-error badge when last_sync_error is unset', () => {
const wrapper = mount(NodeList, {
props: {
nodes: [trustedNode],
loading: false,
error: '',
syncResults: [],
dwnSyncDotClass: 'bg-green-400',
cleaningNodes: false,
},
})
expect(wrapper.find('[data-testid="sync-error-badge"]').exists()).toBe(false)
})
// Peers (Observer level) render in a separate column — the badge must be
// on that row too, or a failing peer stays invisible.
it('shows the sync-error badge on an observer peer row', () => {
const peer: FederatedNode = {
...trustedNode,
did: 'did:key:z6MkPeerNode',
trust_level: 'observer',
name: 'Peer Node',
last_sync_error: 'Peer returned 502 (via tor)',
last_sync_error_at: '2026-06-11T00:00:00Z',
}
const wrapper = mount(NodeList, {
props: {
nodes: [peer],
loading: false,
error: '',
syncResults: [],
dwnSyncDotClass: 'bg-green-400',
cleaningNodes: false,
},
})
expect(wrapper.find('[data-testid="sync-error-badge"]').exists()).toBe(true)
})
})
+64
View File
@@ -0,0 +1,64 @@
export interface AppStatus {
id: string
status: string
version?: string
}
export interface NodeState {
timestamp: string
apps: AppStatus[]
cpu_usage_percent?: number
mem_used_bytes?: number
mem_total_bytes?: number
disk_used_bytes?: number
disk_total_bytes?: number
uptime_secs?: number
tor_active?: boolean
}
export interface FederatedNode {
did: string
pubkey: string
onion: string
trust_level: string
added_at: string
name?: string
last_seen?: string
last_state?: NodeState
/** bech32 FIPS npub this peer advertised (when known). */
fips_npub?: string
/** Transport used on the most recent successful reach: 'fips' | 'tor' | 'mesh' | 'lan'. */
last_transport?: 'fips' | 'tor' | 'mesh' | 'lan'
/** RFC 3339 timestamp of last_transport. */
last_transport_at?: string
/**
* Error from the most recent federation sync attempt with this peer, or
* absent when the last attempt succeeded. Persisted per peer so a stale
* peer is visibly different from a healthy one instead of the failure
* living only in the node's debug log (FED-02).
*/
last_sync_error?: string
/** RFC 3339 timestamp of last_sync_error. */
last_sync_error_at?: string
/**
* How this peer's trust level came to be what it is. `null` means it was
* recorded before provenance was tracked — which is exactly the population
* worth reviewing, since it may include grants made by the fail-open paths
* that `uninvited-join` / `transitive-merge` now cap at Observer.
*/
trust_source?: 'invite' | 'uninvited-join' | 'transitive-merge' | 'manual' | null
}
export interface DwnStatus {
sync_status: string
last_sync: string | null
messages_synced: number
message_count: number
}
export interface SyncResult {
did: string
status: string
apps?: number
error?: string
}
+72
View File
@@ -0,0 +1,72 @@
/** User-friendly node display name. Prefers name, falls back to "Node-XXXX" from DID hash. */
export function nodeName(node: { name?: string | null; did: string }): string {
if (node.name) return node.name
const suffix = node.did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
return `Node-${suffix}`
}
/** Look up display name from DID using a node list. */
export function nodeNameFromDid(did: string, nodes: { name?: string; did: string }[]): string {
const node = nodes.find(n => n.did === did)
if (node) return nodeName(node)
const suffix = did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
return `Node-${suffix}`
}
export function shortDid(did: string): string {
if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8)
}
export function timeAgo(iso: string): string {
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'
return Math.floor(seconds / 86400) + 'd ago'
}
export function formatTimeAgo(iso: string): string {
if (!iso || iso === 'never') return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 60000) return 'just now'
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`
return `${Math.floor(ms / 86400000)}d ago`
}
export function formatBytes(bytes?: number): string {
if (bytes == null || bytes === 0) return '--'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let val = bytes
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return val.toFixed(1) + ' ' + units[i]
}
export function formatUptime(secs: number): string {
const days = Math.floor(secs / 86400)
const hours = Math.floor((secs % 86400) / 3600)
if (days > 0) return `${days}d ${hours}h`
const mins = Math.floor((secs % 3600) / 60)
return `${hours}h ${mins}m`
}
export function trustBadgeClass(level: string): string {
switch (level) {
case 'trusted': return 'bg-green-400/20 text-green-400'
case 'observer': return 'bg-blue-400/20 text-blue-400'
case 'untrusted': return 'bg-white/10 text-white/50'
default: return 'bg-white/10 text-white/50'
}
}
export function isOnline(node: { last_seen?: string }): 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
}