Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 76cfd55971
1578 changed files with 332870 additions and 0 deletions
@@ -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,61 @@
<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 below title -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 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>
</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,171 @@
<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="emit('change-trust', node!.did, ($event.target as HTMLSelectElement).value)"
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>
</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 { 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
}>()
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('')
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>
+198
View File
@@ -0,0 +1,198 @@
<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>
</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>
</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
}
}
</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,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,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,32 @@
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...')
})
})
+48
View File
@@ -0,0 +1,48 @@
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
}
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
}