Show durable connection requests and timestamped node availability

This commit is contained in:
archipelago
2026-10-05 23:10:34 -04:00
parent 10d31ae13c
commit 0c25449566
20 changed files with 473 additions and 142 deletions
@@ -0,0 +1,73 @@
# IndeeHub distribution: current implementation design
Status: design for the post-1.9 follow-up, not implemented/accepted. Earlier
swarm plans describe historical experiments and must not be read as live proof.
## Standards checked on 2026-10-05
- Nostr [NIP-71](https://github.com/nostr-protocol/nips/blob/master/71.md)
defines video metadata, including addressable normal-video kind34235. Use a
stable producer/project identifier for revisions. This does not define paid
viewing rights. Hash/media metadata follows
[NIP-94](https://github.com/nostr-protocol/nips/blob/master/94.md).
- [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) authenticates
HTTP requests; it is not proof of payment or permission to another creator's
project. Keep the existing verified app session and project ownership checks.
- [Cashu NUT-18](https://github.com/cashubtc/nuts/blob/main/18.md) supplies payment
request negotiation. [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md)
covers mint quotes/issuance; method-specific current specifications and older
deployed mint responses must both be capability-tested. Keep quote identifiers
private to the receiving wallet. Payment settlement must be correlated to the
purchase, never inferred from a change in wallet balance.
- [NUT-19](https://github.com/cashubtc/nuts/blob/main/19.md) provides mint-side
cached responses where supported. It supplements a durable local payment
journal; it does not replace one or make an arbitrary retry safe.
- [LNURL-pay](https://github.com/lnurl/luds/blob/luds/06.md) supports an invoice
handoff. A Lightning address by itself is not a signed settlement receipt.
## Required implementation contract
1. Backstage publishes only the selected, owned project. Announcements contain
public metadata, producer identity, node identity, content hashes, current
price/window and accepted-method capabilities. Never publish Cloud paths,
mint quotes, tokens, paid media keys or private management addresses.
2. Each instance's Archipelago source verifies signatures, identity bindings and
monotonic event revisions. Persist discovery so a relay outage does not empty
an existing library. Keep other sources and the current default intact.
3. A purchase binds buyer identity, publisher, project revision, amount, currency,
selected payment method and an unpredictable idempotency identifier. Persist
the quote before requesting payment. Snapshot the offer so later edits cannot
silently change the purchased terms.
4. Reuse file-payment capability negotiation and proven settlement primitives.
Existing file Lightning invoices currently require LND; therefore adding
first-use Lightning-to-Cashu receiving is real work, not a display label. The
receiver must verify a correlated invoice/mint receipt and recover issuance
after a lost response before granting access. Its provisioned ecash address
must map to the actual receiving wallet. No new real payment is authorized by
this design; use isolated fixtures until a bounded payment is approved.
5. A paid purchase creates one durable entitlement. Default demo window proposal:
start at the first successfully authorized media response; persist start and
expiry atomically. Retries, seek and reconnect reuse it without another
payment. Check the entitlement for every media/range/key request. Clock
rollback must not extend an already-started window.
6. Browser/companion playback remains ordinary authenticated local HTTP. Actual
inter-node media bytes use FIPS, with a bound node identity and authenticated
purchase capability. No silent Tor/LAN/iroh fallback for required FIPS media.
Show a recoverable unavailable route without requesting another payment.
7. Range/segment serving streams bounded buffers with backpressure and cancel
propagation. Do not read an entire paid film into a Vec before serving it.
Verify length/hash/revision, reject malformed ranges and path escapes, and
keep cache access subject to the same entitlement. Delivered plaintext cannot
be made impossible to copy; expiry controls subsequent authorized delivery.
## Qualification that remains required
Publisher/receiver integration must cover altered metadata, forged identities,
wrong mint, rejected/late/duplicate payment, missing transaction response, restart,
clock change, expired window, revocation, missing FIPS route, seek and disconnect.
Measure real media-byte transport and memory use. Test the actual Backstage,
Archipelago listing, purchase and player on mobile/desktop and companion.
Use only the operator-designated Yaya Cloud video, preserve the source, and
publish the IndeeHub app image/catalog update at the end of qualification.
One working fixture is not acceptance of the complete distributed flow.
+64
View File
@@ -0,0 +1,64 @@
# Peer requests, delivery, and availability follow-up
Status: implementation under qualification. Not a claim of reciprocal live-node
acceptance or completion of the post-1.9 backlog.
## Confirmed failures
Yaya retained the dev node's approved inbound request while the dev node retained
its outbound Sent request, without reciprocal federation membership. Approval
previously had no durable delivery/retry record. Configured managed relays were
also omitted from reply publication; that separate repair is in 9a041bed.
The Connected Nodes card cached untimestamped reachability booleans, with cached
results overriding the shared store. A failing RPC was rendered as an offline
route. Nostr requests were absent from its Requests tab and badge.
## Changes
- Persist the approval decision and a node-key-encrypted reply before delivery.
Retry the same invite with bounded exponential backoff, at most four eligible
replies per background pass. Relay acknowledgement alone does not remove it;
reciprocal membership does. Removed peers and expired requests are excluded.
- Recover legacy Approved rows through the same supported delivery path. Do not
edit peer files by hand or elevate Observer relationships to Trusted.
- Serialize pending-store mutations and replace its private file atomically.
Malformed storage is preserved and fails explicitly. Conflicting decisions
cannot both win. Approved requests expire after 30 days to permit reconnect.
- Validate the invite's DID and key against the requested identity, normalize
discovery trust to Observer before acceptance and callback.
- Poll in the background every 30 seconds, skipping missed ticks.
- Show Nostr requests in Connected Nodes with a direct link to review them.
Preserve pending rows on failed refresh, and surface partial failures.
- Render independently arriving node lists, limit reachability probes to four,
ignore superseded replies and age timestamped reachability after 90 seconds.
An RPC failure is unknown; an explicit failed reachability check is unreachable.
Report last successful contact without inventing continuous offline duration.
- Place online nodes first, then unknown and unreachable, preserving order within
each group. Fleet describes stale reports as Not reporting. Map labels include
last contact and dashed links indicate no recent contact, not a live route.
## Evidence so far
- Original full backend candidate: 1,693 passed, 4 ignored, no failures, through
the isolated runner (`/tmp/archy-peering-full-backend.log`).
- Additional review added recovery-through-real-relay and retry-backoff checks;
all 50 focused federation tests pass, including actual encrypted relay delivery
for legacy Approved rows and suppression of duplicate attempts during backoff.
Final formatted source also passes all 1,693 backend tests (4 ignored).
- Connected Nodes: 9 focused tests pass, including independent rendering,
mixed failed/negative/successful probes, Nostr requests, cache age and clock skew.
- Fleet/request display: 13 focused tests pass.
- Type checking and production UI build pass. Final frontend suite: 1,238 tests
in 153 files pass (`/tmp/archy-peering-full-ui-final.log`).
- Yaya Chromium at 390 and 1440px passes candidate-asset browser checks with
deterministic peer RPC fixtures: availability text/order, approved Nostr requests,
connection navigation and no page errors (`/tmp/archy-peering-browser-candidate-7.log`).
Fixture checks are not evidence of actual reciprocal membership.
- Clean backend artifact at 10d31ae1: SHA256
`120bd0f51fbceafeceb5557117a442fffc432a7b4d5115da1a163e472f7160da`.
Actual-node deployment and reciprocal membership checks remain required.
The prior reply-rejection test expected a Pending row. This revision deliberately
supersedes that behavior: the decision remains Approved with delivery pending,
so a relay outage does not undo an operator decision or require reapproval.
+12 -3
View File
@@ -1,8 +1,17 @@
# Work requested after 1.9.0-alpha # Work requested after 1.9.0-alpha
Status: queued by the operator on 2026-10-05. Complete the current release first; Status: implementation and qualification in progress. 1.9.0-alpha was published
these requests do not silently expand its artifact scope. No implementation or separately; these follow-ups are not in its immutable artifacts. The list below
acceptance is claimed by this backlog. remains the complete acceptance scope, not a claim that every item is finished.
Current evidence is recorded in [Fleet metrics](fleet-metrics-followup.md),
[peering reliability](peering-reliability-followup.md), and the
[IndeeHub design review](indeehub-distribution-current-design.md). Monitoring and
the tested signer/dashboard candidate are deployed to dev and Yaya with rollback
backups; actual IndeeHub image publication is deferred until the end as requested.
Framework's authenticated Monitoring check awaits an operator dashboard login.
Streaming, storage-source integrations, AIUI setup, V4V packaging/player,
companion hardware checks and the full Fleet acceptance matrix remain open.
## 1. Distributed IndeeHub publishing and paid viewing ## 1. Distributed IndeeHub publishing and paid viewing
@@ -12,6 +12,7 @@
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</span> <span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span> <span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span> <span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
<span class="node-map-legend-item">Dashed: no recent contact</span>
<span v-if="requests?.length" class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.pending }"></span>Request</span> <span v-if="requests?.length" class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.pending }"></span>Request</span>
</div> </div>
<!-- 2D/3D projection toggle — tweens the camera between the flat radial <!-- 2D/3D projection toggle — tweens the camera between the flat radial
@@ -81,6 +82,7 @@ export interface MapNode {
label: string label: string
trust_level: 'trusted' | 'observer' | 'untrusted' trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean online: boolean
statusLabel?: string
app_count: number app_count: number
is_self: boolean is_self: boolean
} }
@@ -162,6 +164,7 @@ interface PeerVis {
dying: boolean dying: boolean
el: SVGGElement el: SVGGElement
label: SVGTextElement label: SVGTextElement
statusText: SVGTextElement
linkEl: SVGPathElement | null linkEl: SVGPathElement | null
dots: GlobeDot[] dots: GlobeDot[]
radiusPx: number radiusPx: number
@@ -461,9 +464,15 @@ function createPeer(node: MapNode, slot: { angle: number; ringRadius: number },
fill: motionTokens.color.textSecondary, 'font-size': '11px', 'font-family': FONT, fill: motionTokens.color.textSecondary, 'font-size': '11px', 'font-family': FONT,
}) })
label.textContent = node.label label.textContent = node.label
const statusText = el('text', {
dy: String(r + 28), 'text-anchor': 'middle', fill: motionTokens.color.textSecondary,
'font-size': '9px', 'font-family': FONT,
})
statusText.textContent = node.statusLabel || (node.online ? 'Online' : 'Status unknown')
const title = el('title') const title = el('title')
title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}` title.textContent = `${node.did}\nApps: ${node.app_count}\n${statusText.textContent}`
g.append(label, title) g.setAttribute('aria-label', `${node.label}: ${statusText.textContent}`)
g.append(label, statusText, title)
g.addEventListener('click', () => emit('select', node.did)) g.addEventListener('click', () => emit('select', node.did))
nodesLayer!.appendChild(g) nodesLayer!.appendChild(g)
@@ -483,6 +492,7 @@ function createPeer(node: MapNode, slot: { angle: number; ringRadius: number },
dying: false, dying: false,
el: g, el: g,
label, label,
statusText,
linkEl, linkEl,
dots, dots,
radiusPx: r, radiusPx: r,
@@ -672,7 +682,9 @@ function applyGraph() {
existing.node = node existing.node = node
if (existing.label.textContent !== node.label) existing.label.textContent = node.label if (existing.label.textContent !== node.label) existing.label.textContent = node.label
const title = existing.el.querySelector('title') const title = existing.el.querySelector('title')
if (title) title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}` existing.statusText.textContent = node.statusLabel || (node.online ? 'Online' : 'Status unknown')
existing.el.setAttribute('aria-label', `${node.label}: ${existing.statusText.textContent}`)
if (title) title.textContent = `${node.did}\nApps: ${node.app_count}\n${existing.statusText.textContent}`
stylePeerDom(existing) stylePeerDom(existing)
} }
@@ -1249,7 +1261,7 @@ function initialBuild() {
} }
const graphSignature = computed(() => JSON.stringify({ const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]), nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.statusLabel, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]), links: props.links.map(l => [l.source, l.target]),
requests: (props.requests ?? []).map(r => [r.id, r.label]), requests: (props.requests ?? []).map(r => [r.id, r.label]),
})) }))
@@ -6,6 +6,7 @@ import { useWeb5BadgeStore } from '../web5Badge'
vi.mock('@/api/rpc-client', () => ({ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
call: vi.fn(), call: vi.fn(),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
}, },
})) }))
@@ -75,4 +76,25 @@ describe('useWeb5BadgeStore', () => {
await store.refresh() await store.refresh()
expect(store.pendingRequestCount).toBe(2) expect(store.pendingRequestCount).toBe(2)
}) })
it('counts incoming Nostr requests while excluding outbound and completed requests', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [{ id: 'legacy' }] })
const common = { from_did: 'did:key:peer', from_nostr_pubkey: 'key', from_nostr_npub: 'npub', from_name: null, message: null, received_at: new Date().toISOString() }
vi.mocked(rpcClient.federationListPendingRequests).mockResolvedValueOnce({ requests: [
{ ...common, id: 'incoming', state: 'pending', outbound: false },
{ ...common, id: 'outgoing', state: 'sent', outbound: true },
{ ...common, id: 'approved', state: 'approved', outbound: false },
] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(2)
})
it('preserves the previous badge when the Nostr source cannot be refreshed', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
vi.mocked(rpcClient.federationListPendingRequests).mockRejectedValueOnce(new Error('offline'))
const store = useWeb5BadgeStore()
store.pendingRequestCount = 3
await store.refresh()
expect(store.pendingRequestCount).toBe(3)
})
}) })
+12 -5
View File
@@ -6,11 +6,18 @@ export const useWeb5BadgeStore = defineStore('web5Badge', () => {
const pendingRequestCount = ref(0) const pendingRequestCount = ref(0)
async function refresh() { async function refresh() {
try { const results = await Promise.allSettled([
const res = await rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' }) rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' }),
pendingRequestCount.value = res.requests?.length ?? 0 rpcClient.federationListPendingRequests(),
} catch (e) { ])
if (import.meta.env.DEV) console.warn('Badge refresh failed — best-effort', e) // Do not clear an existing badge when a request source is unavailable.
if (results.every(result => result.status === 'fulfilled')) {
const legacy = results[0]
const nostr = results[1]
if (legacy?.status === 'fulfilled' && nostr?.status === 'fulfilled') {
pendingRequestCount.value = (legacy.value.requests?.length ?? 0) +
(nostr.value.requests?.filter(request => request.state === 'pending' && !request.outbound).length ?? 0)
}
} }
} }
+7 -4
View File
@@ -181,6 +181,7 @@
<PendingRequestsPanel <PendingRequestsPanel
:requests="pendingRequests" :requests="pendingRequests"
:peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake" :polling="pollingHandshake"
:busy-id="pendingBusyId" :busy-id="pendingBusyId"
@poll="pollHandshake" @poll="pollHandshake"
@@ -259,6 +260,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { validContact, lastContactLabel } from './web5/nodeAvailability'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource' import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport' import { useTransportStore } from '@/stores/transport'
@@ -347,6 +349,7 @@ const selfDid = ref<string>((() => {
try { return localStorage.getItem('neode_did') || '' } catch { return '' } try { return localStorage.getItem('neode_did') || '' } catch { return '' }
})()) })())
const availabilityNow = ref(Date.now())
const mapNodes = computed(() => { const mapNodes = computed(() => {
const result = [] const result = []
if (selfDid.value) { if (selfDid.value) {
@@ -365,6 +368,7 @@ const mapNodes = computed(() => {
label: nodeName(node), label: nodeName(node),
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted', trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
online: isOnlineCheck(node), online: isOnlineCheck(node),
statusLabel: isOnlineCheck(node) ? 'Online' : lastContactLabel(node.last_seen, availabilityNow.value),
app_count: node.last_state?.apps?.length ?? 0, app_count: node.last_state?.apps?.length ?? 0,
is_self: false, is_self: false,
}) })
@@ -570,10 +574,8 @@ async function cancelPending(id: string) {
} }
function isOnlineCheck(node: FederatedNode): boolean { function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false const lastSeen = validContact(node.last_seen, availabilityNow.value)
const lastSeen = new Date(node.last_seen).getTime() return lastSeen !== undefined && availabilityNow.value - lastSeen < 10 * 60 * 1000
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
return lastSeen > tenMinutesAgo
} }
/** Explicit reload (mutations, retry): surfaces a load failure in the error /** Explicit reload (mutations, retry): surfaces a load failure in the error
@@ -830,6 +832,7 @@ onMounted(async () => {
// Self DID not available // Self DID not available
} }
autoRefreshTimer = setInterval(() => { autoRefreshTimer = setInterval(() => {
availabilityNow.value = Date.now()
void nodesRes.refresh() void nodesRes.refresh()
loadPendingRequests() loadPendingRequests()
}, 5000) }, 5000)
@@ -1,12 +1,12 @@
<template> <template>
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6"> <div id="connection-requests" v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2> <h2 class="text-base font-semibold text-white">Nostr Connection Requests</h2>
<p class="text-xs text-white/60"> <p class="text-xs text-white/60">
Inbound requests await your approval. Outbound requests show what you've sent. 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 Connections become <span class="text-orange-300">Observer</span> peers.
to Trusted manually if you want them to receive state syncs. Approval delivery retries automatically while the other node connects.
</p> </p>
</div> </div>
<button <button
@@ -80,6 +80,7 @@ const props = defineProps<{
requests: PendingPeerRequest[] requests: PendingPeerRequest[]
polling: boolean polling: boolean
busyId: string | null busyId: string | null
peerDids?: string[]
}>() }>()
defineEmits<{ defineEmits<{
@@ -96,6 +97,7 @@ const visibleRequests = computed(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000 const cutoff = Date.now() - 24 * 60 * 60 * 1000
return props.requests.filter((r) => { return props.requests.filter((r) => {
if (r.state === 'pending' || r.state === 'sent') return true if (r.state === 'pending' || r.state === 'sent') return true
if (r.state === 'approved' && !props.peerDids?.includes(r.from_did)) return true
const ts = new Date(r.received_at).getTime() const ts = new Date(r.received_at).getTime()
return Number.isFinite(ts) && ts >= cutoff return Number.isFinite(ts) && ts >= cutoff
}) })
@@ -104,14 +106,20 @@ const visibleRequests = computed(() => {
function badgeClass(r: PendingPeerRequest): string { function badgeClass(r: PendingPeerRequest): string {
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300' 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 === 'sent') return 'bg-blue-500/20 text-blue-300'
if (r.state === 'approved') return 'bg-green-500/20 text-green-300' if (r.state === 'approved') return props.peerDids?.includes(r.from_did)
? 'bg-green-500/20 text-green-300' : 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300' if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
return 'bg-white/10 text-white/50' return 'bg-white/10 text-white/50'
} }
function badgeLabel(r: PendingPeerRequest): string { function badgeLabel(r: PendingPeerRequest): string {
if (r.outbound && r.state === 'sent') return 'Sent' if (r.outbound && r.state === 'sent') return 'Sent'
if (r.outbound && r.state === 'approved') return 'They approved' if (r.state === 'approved') {
if (props.peerDids?.includes(r.from_did)) return 'Peer added'
const age = Date.now() - new Date(r.received_at).getTime()
if (!Number.isFinite(age) || age > 30 * 24 * 60 * 60 * 1000) return 'Approval expired — reconnect'
return r.outbound ? 'They approved — connecting' : 'Approved — connecting'
}
if (r.outbound && r.state === 'rejected') return 'They rejected' if (r.outbound && r.state === 'rejected') return 'They rejected'
if (r.outbound) return r.state if (r.outbound) return r.state
if (r.state === 'pending') return 'Inbound' if (r.state === 'pending') return 'Inbound'
@@ -126,7 +134,7 @@ function shortNpub(npub: string): string {
function relative(iso: string): string { function relative(iso: string): string {
const ts = new Date(iso).getTime() const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return iso if (!Number.isFinite(ts)) return iso
const diff = Date.now() - ts const diff = Math.max(0, Date.now() - ts)
const sec = Math.floor(diff / 1000) const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s ago` if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60) const min = Math.floor(sec / 60)
@@ -0,0 +1,25 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import PendingRequestsPanel from '../PendingRequestsPanel.vue'
import type { PendingPeerRequest } from '@/api/rpc-client'
const request: PendingPeerRequest = {
id: 'request', from_did: 'did:key:peer', from_nostr_pubkey: 'key', from_nostr_npub: 'npub',
from_name: 'Other node', message: null, outbound: false, state: 'approved',
received_at: new Date(Date.now()-2*86400000).toISOString(),
}
describe('Nostr connection progress', () => {
it('keeps unresolved approvals visible and distinguishes local membership', async () => {
const wrapper=mount(PendingRequestsPanel,{props:{requests:[request],polling:false,busyId:null,peerDids:[]}})
expect(wrapper.text()).toContain('Approved — connecting')
expect(wrapper.text()).not.toContain('Peer added')
await wrapper.setProps({requests:[{...request,received_at:new Date().toISOString()}],peerDids:[request.from_did]})
expect(wrapper.text()).toContain('Peer added')
expect(wrapper.text()).not.toContain('Approved — connecting')
wrapper.unmount()
})
it('explains expired approval instead of claiming a connection is still progressing', () => {
const wrapper=mount(PendingRequestsPanel,{props:{requests:[{...request,received_at:new Date(Date.now()-31*86400000).toISOString()}],polling:false,busyId:null}})
expect(wrapper.text()).toContain('Approval expired — reconnect')
wrapper.unmount()
})
})
+1 -1
View File
@@ -84,7 +84,7 @@
</div> </div>
<div class="flex flex-wrap gap-x-3 gap-y-1 items-center justify-between text-xs text-white/40 mt-1"> <div class="flex flex-wrap gap-x-3 gap-y-1 items-center justify-between text-xs text-white/40 mt-1">
<span>{{ node.uptime_secs === null ? 'Uptime unavailable' : 'Up ' + formatUptime(node.uptime_secs) }}</span> <span>{{ node.uptime_secs === null ? 'Uptime unavailable' : 'Up ' + formatUptime(node.uptime_secs) }}</span>
<span>{{ fleetStatus(node.reported_at) === 'unknown' ? 'Status unknown' : fleetStatus(node.reported_at) === 'offline' ? 'Offline · last seen ' + timeAgo(node.reported_at) : 'Seen ' + timeAgo(node.reported_at) }}</span> <span>{{ fleetStatus(node.reported_at) === 'unknown' ? 'Status unknown' : fleetStatus(node.reported_at) === 'stale' ? 'Not reporting · last seen ' + timeAgo(node.reported_at) : 'Seen ' + timeAgo(node.reported_at) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -5,7 +5,7 @@
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p> <p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
<p class="text-xs text-white/40"> <p class="text-xs text-white/40">
<span class="fleet-dot-online"></span> {{ onlineCount }} online <span class="fleet-dot-online"></span> {{ onlineCount }} online
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline <span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} not reporting
<span v-if="unknownCount" class="ml-1">{{ unknownCount }} unknown</span> <span v-if="unknownCount" class="ml-1">{{ unknownCount }} unknown</span>
</p> </p>
</div> </div>
@@ -18,7 +18,7 @@ describe('fleet unavailable readings', () => {
expect(cards).toHaveLength(2) expect(cards).toHaveLength(2)
expect(cards[0]!.text()).toContain('0%') expect(cards[0]!.text()).toContain('0%')
expect(cards[0]!.text()).toContain('—') expect(cards[0]!.text()).toContain('—')
expect(cards[0]!.text()).toContain('Offline · last seen 2d ago') expect(cards[0]!.text()).toContain('Not reporting · last seen 2d ago')
expect(cards[1]!.text()).toContain('Status unknown') expect(cards[1]!.text()).toContain('Status unknown')
expect(cards[1]!.text()).not.toContain('0%') expect(cards[1]!.text()).not.toContain('0%')
expect(cards[1]!.text()).toContain('Uptime unavailable') expect(cards[1]!.text()).toContain('Uptime unavailable')
@@ -46,7 +46,7 @@ describe('fleet data helpers', () => {
vi.useRealTimers() vi.useRealTimers()
}) })
it('sorts status with online nodes first, then latest report', () => { it('sorts status with online nodes first without shuffling within a status group', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z')) vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const nodes = [ const nodes = [
@@ -56,8 +56,8 @@ describe('fleet data helpers', () => {
] ]
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([ expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
'online-new',
'online-old', 'online-old',
'online-new',
'offline', 'offline',
]) ])
@@ -133,9 +133,9 @@ describe('fleet missing data and clock boundaries', () => {
expect(isOnline(value)).toBe(false) expect(isOnline(value)).toBe(false)
expect(timeAgo(value)).toBe('Unknown') expect(timeAgo(value)).toBe('Unknown')
} }
expect(fleetStatus('2026-06-10T11:30:00Z')).toBe('offline') expect(fleetStatus('2026-06-10T11:30:00Z')).toBe('stale')
expect(fleetStatus('2026-06-10T12:00:30Z')).toBe('online') expect(fleetStatus('2026-06-10T12:00:30Z')).toBe('online')
expect(sortFleetNodes([node('unknown', ''), node('future', '2027-01-01T00:00:00Z'), node('old', '2026-06-10T10:00:00Z'), node('new', '2026-06-10T11:59:00Z')], 'status').map(n => n.node_id)).toEqual(['new', 'old', 'unknown', 'future']) expect(sortFleetNodes([node('unknown', ''), node('future', '2027-01-01T00:00:00Z'), node('old', '2026-06-10T10:00:00Z'), node('new', '2026-06-10T11:59:00Z')], 'status').map(n => n.node_id)).toEqual(['new', 'unknown', 'future', 'old'])
}) })
it('keeps valid zero separate from unavailable and invalid measurements', () => { it('keeps valid zero separate from unavailable and invalid measurements', () => {
expect(normalizeFleetNode({cpu_pct: 0}).cpu_pct).toBe(0) expect(normalizeFleetNode({cpu_pct: 0}).cpu_pct).toBe(0)
+5 -7
View File
@@ -70,10 +70,10 @@ export function timeAgo(dateStr: string): string {
return `${diffDays}d ago` return `${diffDays}d ago`
} }
export function fleetStatus(reportedAt: string): 'online' | 'offline' | 'unknown' { export function fleetStatus(reportedAt: string): 'online' | 'stale' | 'unknown' {
const age = Date.now() - new Date(reportedAt).getTime() const age = Date.now() - new Date(reportedAt).getTime()
if (!Number.isFinite(age) || age < -60_000) return 'unknown' if (!Number.isFinite(age) || age < -60_000) return 'unknown'
return age < 30 * 60 * 1000 ? 'online' : 'offline' return age < 30 * 60 * 1000 ? 'online' : 'stale'
} }
export function isOnline(reportedAt: string): boolean { export function isOnline(reportedAt: string): boolean {
@@ -159,10 +159,8 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
switch (sortBy) { switch (sortBy) {
case 'status': case 'status':
sorted.sort((a, b) => { sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at) const rank = { online: 0, unknown: 1, stale: 2 }
const bOnline = isOnline(b.reported_at) return rank[fleetStatus(a.reported_at)] - rank[fleetStatus(b.reported_at)]
if (aOnline !== bOnline) return aOnline ? -1 : 1
return reportTime(b) - reportTime(a)
}) })
break break
case 'last-seen': case 'last-seen':
@@ -277,7 +275,7 @@ export function useFleetData() {
// --- Computed --- // --- Computed ---
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length) const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
const offlineCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'offline').length) const offlineCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'stale').length)
const unknownCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'unknown').length) const unknownCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'unknown').length)
const healthyCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at) && n.cpu_pct !== null && n.mem_pct !== null && n.disk_pct !== null && n.recent_alerts.length === 0).length) const healthyCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at) && n.cpu_pct !== null && n.mem_pct !== null && n.disk_pct !== null && n.recent_alerts.length === 0).length)
+110 -96
View File
@@ -25,7 +25,7 @@
</div> </div>
<!-- Tabs: Trusted | Observers | Messages | Requests --> <!-- Tabs: Trusted | Observers | Messages | Requests -->
<div class="flex gap-1 mb-4 border-b border-white/10"> <div class="flex gap-1 mb-4 border-b border-white/10 overflow-x-auto whitespace-nowrap shrink-0">
<button <button
@click="nodesContainerTab = 'trusted'" @click="nodesContainerTab = 'trusted'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors" class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
@@ -48,11 +48,12 @@
:class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'" :class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
> >
{{ t('web5.requests') }} {{ t('web5.requests') }}
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</span> <span v-if="requestCount > 0" class="ml-1.5 text-xs text-orange-400">({{ requestCount }})</span>
<span v-if="connectionRequests.length > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span> <span v-if="requestCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button> </button>
</div> </div>
<p v-if="peersError && nodesContainerTab !== 'requests'" role="status" class="text-xs text-amber-200 mb-2">{{ peersError }}</p>
<!-- Trusted tab --> <!-- Trusted tab -->
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none"> <div v-show="nodesContainerTab === 'trusted'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm"> <div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
@@ -65,16 +66,16 @@
{{ t('common.loading') }} {{ t('common.loading') }}
</div> </div>
<div <div
v-for="p in peers" v-for="p in sortedPeers"
:key="p.pubkey" :key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })" @click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors" class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
> >
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div> <div class="w-2 h-2 rounded-full shrink-0" :class="statusClass(p)"></div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p> <p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p> <p class="text-xs text-white/50 truncate">{{ availabilityText(p) }} · {{ contactText(p) }}</p>
</div> </div>
</div> </div>
<button <button
@@ -98,16 +99,16 @@
{{ t('common.loading') }} {{ t('common.loading') }}
</div> </div>
<div <div
v-for="p in observers" v-for="p in sortedObservers"
:key="p.pubkey" :key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })" @click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors" class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
> >
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div> <div class="w-2 h-2 rounded-full shrink-0" :class="statusClass(p)"></div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p> <p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p> <p class="text-xs text-white/50 truncate">{{ availabilityText(p) }} · {{ contactText(p) }}</p>
</div> </div>
</div> </div>
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0"> <span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
@@ -118,10 +119,17 @@
<!-- Requests tab --> <!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none"> <div v-show="nodesContainerTab === 'requests'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm"> <p v-if="requestError" role="status" class="text-xs text-amber-200">{{ requestError }}</p>
<button v-for="request in nostrRequests" :key="'nostr:' + request.id"
class="w-full text-left p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
@click="router.push({ path: '/dashboard/server/federation', hash: '#connection-requests' })">
<span class="block text-sm text-white truncate">{{ request.from_name || request.from_did || request.from_nostr_npub }}</span>
<span class="block text-xs text-white/60">Nostr · {{ request.state === 'approved' ? 'Approved — connecting' : request.outbound ? 'Sent — awaiting approval' : 'Incoming — review request' }}</span>
</button>
<div v-if="loadingRequests && requestCount === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }} {{ t('common.loading') }}
</div> </div>
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm"> <div v-else-if="requestCount === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noRequests') }} {{ t('web5.noRequests') }}
</div> </div>
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs"> <div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
@@ -231,13 +239,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast' import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge' import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app' import type { PendingPeerRequest } from '@/api/rpc-client'
import { availabilityRank, lastContactLabel, nodeAvailability, validContact, type ReachabilitySample } from './nodeAvailability'
import { useModalKeyboard } from '@/composables/useModalKeyboard' import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { formatMessageTime } from './utils' import { formatMessageTime } from './utils'
import type { Peer, ConnectionRequest } from './types' import type { Peer, ConnectionRequest } from './types'
@@ -246,13 +255,13 @@ const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const messageToast = useMessageToast() const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore() const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v2'
type ConnectedNodesCache = { type ConnectedNodesCache = {
peers: Peer[] peers: Peer[]
observers: Peer[] observers: Peer[]
peerReachable: Record<string, boolean> peerReachable: Record<string, ReachabilitySample>
connectionRequests: ConnectionRequest[] connectionRequests: ConnectionRequest[]
} }
@@ -289,8 +298,27 @@ const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? []) const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? []) const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false) const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {}) const peerReachableLocal = ref<Record<string, ReachabilitySample>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value })) const now = ref(Date.now())
let ageTimer: ReturnType<typeof setInterval> | undefined
let peerGeneration = 0
onMounted(() => { ageTimer = setInterval(() => { now.value = Date.now() }, 15_000) })
onUnmounted(() => { clearInterval(ageTimer); peerGeneration++ })
function availability(peer: Peer) { return nodeAvailability(peerReachableLocal.value[peer.onion], now.value) }
function availabilityText(peer: Peer) {
const status = availability(peer)
return status === 'online' ? 'Online' : status === 'unreachable' ? 'Unreachable' : 'Status unknown'
}
function contactText(peer: Peer) {
const local = validContact(peerReachableLocal.value[peer.onion]?.lastSeen, now.value) ?? 0
const recorded = validContact(peer.last_seen, now.value) ?? 0
return lastContactLabel(Math.max(local, recorded), now.value)
}
function statusClass(peer: Peer) {
return availability(peer) === 'online' ? 'bg-green-400' : availability(peer) === 'unreachable' ? 'bg-amber-400' : 'bg-white/30'
}
const sortedPeers = computed(() => [...peers.value].sort((a, b) => availabilityRank(availability(a)) - availabilityRank(availability(b))))
const sortedObservers = computed(() => [...observers.value].sort((a, b) => availabilityRank(availability(a)) - availabilityRank(availability(b))))
// Send message modal // Send message modal
const showSendMessageModal = ref(false) const showSendMessageModal = ref(false)
@@ -309,6 +337,10 @@ const sendMessageSuccess = ref('')
// Connection requests // Connection requests
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? []) const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
const nostrRequests = ref<PendingPeerRequest[]>([])
const requestCount = computed(() => connectionRequests.value.length + nostrRequests.value.length)
const requestError = ref('')
const peersError = ref('')
const loadingRequests = ref(false) const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null) const processingRequestId = ref<string | null>(null)
@@ -329,13 +361,14 @@ function federationNodeToPeer(node: FederationNode): Peer {
onion: node.onion, onion: node.onion,
pubkey: node.pubkey, pubkey: node.pubkey,
did: node.did, did: node.did,
last_seen: node.last_seen,
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`, name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
} }
} }
function switchToRequestsTab() { function switchToRequestsTab() {
nodesContainerTab.value = 'requests' nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) { if (!loadingRequests.value) {
loadConnectionRequests() loadConnectionRequests()
} }
} }
@@ -346,74 +379,58 @@ function refreshActiveTab() {
else void loadPeers() else void loadPeers()
} }
function persistCache() {
writeConnectedNodesCache({ peers: peers.value, observers: observers.value, peerReachable: peerReachableLocal.value, connectionRequests: connectionRequests.value })
}
async function loadPeers() { async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0 if (loadingPeers.value) return
const generation = ++peerGeneration
loadingPeers.value = true loadingPeers.value = true
try { peersError.value = ''
// Independent RPCs — fetched together (serialized they stacked two full let direct: Peer[] | undefined
// mesh round-trips before anything rendered). let federated: FederationNode[] | undefined
const [res, fedSettled] = await Promise.all([ function applyLists() {
rpcClient.listPeers(), if (generation !== peerGeneration) return
rpcClient.federationListNodes().catch(() => null), const trusted = direct ? [...direct] : [...peers.value]
]) const observerList: Peer[] = federated ? [] : [...observers.value]
const peerList = res.peers || [] for (const node of federated ?? []) {
const observerList: Peer[] = [] if (!node.onion || node.trust_level === 'untrusted') continue
const list = node.trust_level === 'observer' ? observerList : trusted
try { const peer = federationNodeToPeer(node)
const fedNodes = fedSettled?.nodes || [] const index = list.findIndex(p => p.did === node.did || p.onion === node.onion || (p.pubkey && p.pubkey === node.pubkey))
for (const n of fedNodes) { if (index < 0) list.push(peer)
if (!n.onion || n.trust_level === 'untrusted') { else list[index] = { ...list[index], ...peer }
continue
} }
peers.value = trusted
if (n.trust_level === 'observer') {
if (!observerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
observerList.push(federationNodeToPeer(n))
}
continue
}
if (!peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
peerList.push(federationNodeToPeer(n))
}
}
} catch {
// Federation may not be set up
}
peers.value = peerList
observers.value = observerList observers.value = observerList
// The list is ready — render it and re-enable Refresh NOW. The persistCache()
// reachability dots fill in as probes resolve. Before 2026-07-22 the }
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat const results = await Promise.allSettled([
// disabled for N-offline-peers × 30s ("takes ages"). rpcClient.listPeers().then(result => { direct = result.peers || []; applyLists() }),
rpcClient.federationListNodes().then(result => { federated = result.nodes || []; applyLists() }),
])
if (generation !== peerGeneration) return
if (results.some(result => result.status === 'rejected')) peersError.value = 'Some nodes could not be refreshed. Showing available information.'
loadingPeers.value = false loadingPeers.value = false
void Promise.allSettled( // Bound concurrent probes, render each result immediately, and ignore replies
[...peers.value, ...observers.value].map(async (p) => { // from a superseded refresh. A failed RPC is unknown, not an offline node.
try { const pending = [...new Map([...peers.value, ...observers.value].map(peer => [peer.onion, peer])).values()]
const check = await rpcClient.checkPeerReachable(p.onion) async function probe() {
peerReachableLocal.value[p.onion] = check.reachable while (pending.length && generation === peerGeneration) {
} catch { const peer = pending.shift()!
peerReachableLocal.value[p.onion] = false let reachable: boolean | null = null
try { reachable = (await rpcClient.checkPeerReachable(peer.onion)).reachable } catch { /* unknown */ }
if (generation !== peerGeneration) return
now.value = Date.now()
peerReachableLocal.value[peer.onion] = {
reachable,
checkedAt: now.value,
lastSeen: reachable ? now.value : validContact(peerReachableLocal.value[peer.onion]?.lastSeen, now.value),
} }
}), persistCache()
).then(() => {
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
})
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) {
peers.value = []
observers.value = []
} }
} finally {
loadingPeers.value = false
} }
void Promise.all(Array.from({ length: Math.min(4, pending.length) }, probe))
} }
async function sendMessage() { async function sendMessage() {
@@ -437,31 +454,28 @@ async function sendMessage() {
} }
async function loadConnectionRequests() { async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0 if (loadingRequests.value) return
loadingRequests.value = true loadingRequests.value = true
try { requestError.value = ''
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' }) const results = await Promise.allSettled([
connectionRequests.value = res.requests || [] rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' }).then(res => { connectionRequests.value = res.requests || [] }),
web5Badge.pendingRequestCount = connectionRequests.value.length rpcClient.federationListPendingRequests().then(res => {
writeConnectedNodesCache({ nostrRequests.value = (res.requests || []).filter(request => request.state === 'pending' || request.state === 'sent' ||
peers: peers.value, (request.state === 'approved' && ![...peers.value, ...observers.value].some(peer => peer.did === request.from_did)))
observers: observers.value, }),
peerReachable: peerReachableLocal.value, ])
connectionRequests: connectionRequests.value, if (results.some(result => result.status === 'rejected')) requestError.value = 'Some requests could not be refreshed. Try again.'
}) web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
} catch { persistCache()
if (!hadRequests) connectionRequests.value = []
} finally {
loadingRequests.value = false loadingRequests.value = false
} }
}
async function acceptRequest(requestId: string) { async function acceptRequest(requestId: string) {
processingRequestId.value = requestId processingRequestId.value = requestId
try { try {
await rpcClient.call({ method: 'network.accept-request', params: { id: requestId } }) await rpcClient.call({ method: 'network.accept-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId) connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
writeConnectedNodesCache({ writeConnectedNodesCache({
peers: peers.value, peers: peers.value,
observers: observers.value, observers: observers.value,
@@ -482,7 +496,7 @@ async function rejectRequest(requestId: string) {
try { try {
await rpcClient.call({ method: 'network.reject-request', params: { id: requestId } }) await rpcClient.call({ method: 'network.reject-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId) connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length web5Badge.pendingRequestCount = connectionRequests.value.length + nostrRequests.value.filter(request => request.state === 'pending' && !request.outbound).length
writeConnectedNodesCache({ writeConnectedNodesCache({
peers: peers.value, peers: peers.value,
observers: observers.value, observers: observers.value,
@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils' import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue' import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
})) }))
@@ -15,6 +17,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})), listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }), federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }), checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(), call: vi.fn(),
}, },
@@ -41,6 +44,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes', () => { describe('Web5ConnectedNodes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
sessionStorage.clear()
}) })
it('shows a loading state for empty trusted nodes while peers are loading', async () => { it('shows a loading state for empty trusted nodes while peers are loading', async () => {
@@ -83,4 +87,43 @@ describe('Web5ConnectedNodes', () => {
expect(wrapper.text()).toContain('Please connect') expect(wrapper.text()).toContain('Please connect')
}) })
it('shows Nostr requests beside legacy requests without requiring discovery first', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ requests: [] })
vi.mocked(rpcClient.federationListPendingRequests).mockResolvedValueOnce({ requests: [{
id: 'nostr-1', from_did: 'did:key:yaya', from_name: 'Yaya', from_nostr_npub: 'npub1yaya',
from_nostr_pubkey: 'abc', message: null, received_at: new Date().toISOString(), state: 'approved', outbound: false,
}] })
const wrapper = mount(Web5ConnectedNodes)
await wrapper.vm.loadConnectionRequests()
expect(wrapper.text()).toContain('Yaya')
expect(wrapper.text()).toContain('Approved — connecting')
expect(wrapper.text()).not.toContain('web5.noRequests')
})
it('renders direct peers while federation is slow and distinguishes probe failure from unreachable', async () => {
vi.mocked(rpcClient.listPeers).mockResolvedValueOnce({ peers: [
{ onion: 'online.onion', pubkey: 'online', name: 'Available node' },
{ onion: 'failed.onion', pubkey: 'failed', name: 'Query failed' },
{ onion: 'offline.onion', pubkey: 'offline', name: 'No route' },
] })
let finish!: (value: { nodes: [] }) => void
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
vi.mocked(rpcClient.checkPeerReachable).mockImplementation(async onion => {
if (onion === 'failed.onion') throw new Error('unauthorized')
return { onion, reachable: onion === 'online.onion' }
})
const wrapper = mount(Web5ConnectedNodes)
const load = wrapper.vm.loadPeers()
await flushPromises()
expect(wrapper.text()).toContain('Available node')
expect(wrapper.text()).toContain('Status unknown')
finish({ nodes: [] })
await load
await flushPromises()
expect(wrapper.text()).toContain('Online · Last seen just now')
expect(wrapper.text()).toContain('Unreachable · No confirmed contact')
expect(wrapper.text()).toContain('Status unknown · No confirmed contact')
expect(wrapper.text()).not.toContain('Offline for')
})
}) })
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' import { mount, enableAutoUnmount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue' import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// Structural pin for UIFIX-02: the connected-nodes card's height must track // Structural pin for UIFIX-02: the connected-nodes card's height must track
@@ -7,6 +7,8 @@ import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// child), and the tab panes must scroll inside that height rather than // child), and the tab panes must scroll inside that height rather than
// growing to fit every row. See: // growing to fit every row. See:
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
})) }))
@@ -19,6 +21,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})), listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }), federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }), checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(), call: vi.fn(),
}, },
@@ -45,6 +48,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes scroll contract (UIFIX-02)', () => { describe('Web5ConnectedNodes scroll contract (UIFIX-02)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
sessionStorage.clear()
}) })
it('gives all three tab panes the bounded, sibling-matched scroll contract', () => { it('gives all three tab panes the bounded, sibling-matched scroll contract', () => {
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { nodeAvailability, lastContactLabel, validContact, REACHABILITY_MAX_AGE, availabilityRank } from '../nodeAvailability'
describe('node availability observations', () => {
const now = Date.parse('2026-10-06T00:00:00Z')
it('ages cached observations and rejects malformed or future samples', () => {
expect(nodeAvailability({ reachable: true, checkedAt: now }, now)).toBe('online')
expect(nodeAvailability({ reachable: false, checkedAt: now }, now)).toBe('unreachable')
expect(nodeAvailability({ reachable: null, checkedAt: now }, now)).toBe('unknown')
for (const checkedAt of [NaN, now + 120000, now - REACHABILITY_MAX_AGE - 1]) {
expect(nodeAvailability({ reachable: true, checkedAt }, now)).toBe('unknown')
}
expect(nodeAvailability(undefined, now)).toBe('unknown')
})
it('reports last contact without inventing continuous offline duration', () => {
expect(lastContactLabel(now - 2 * 86400000, now)).toBe('Last seen 2d ago')
for (const value of [null, undefined, '', 'broken', 0, now + 120000]) {
expect(validContact(value, now)).toBeUndefined()
expect(lastContactLabel(value, now)).toBe('No confirmed contact')
}
expect(lastContactLabel(now + 30000, now)).toBe('Last seen just now')
expect(['unreachable', 'online', 'unknown'].sort((a, b) => availabilityRank(a as any) - availabilityRank(b as any))).toEqual(['online', 'unknown', 'unreachable'])
})
})
@@ -0,0 +1,25 @@
/** Reachability is an observation, not proof of a continuous outage. */
export interface ReachabilitySample { reachable: boolean | null; checkedAt: number; lastSeen?: number }
export type NodeAvailability = 'online' | 'unreachable' | 'unknown'
export const REACHABILITY_MAX_AGE = 90_000
export function validContact(value: unknown, now: number): number | undefined {
const timestamp = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
return Number.isFinite(timestamp) && timestamp > 0 && timestamp <= now + 60_000 ? timestamp : undefined
}
export function nodeAvailability(sample: ReachabilitySample | undefined, now: number): NodeAvailability {
if (!sample || !Number.isFinite(sample.checkedAt) || now - sample.checkedAt > REACHABILITY_MAX_AGE || sample.checkedAt > now + 60_000) return 'unknown'
return sample.reachable === true ? 'online' : sample.reachable === false ? 'unreachable' : 'unknown'
}
export function lastContactLabel(value: unknown, now: number): string {
const timestamp = validContact(value, now)
if (!timestamp) return 'No confirmed contact'
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000))
if (seconds < 60) return 'Last seen just now'
if (seconds < 3600) return `Last seen ${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `Last seen ${Math.floor(seconds / 3600)}h ago`
return `Last seen ${Math.floor(seconds / 86400)}d ago`
}
export function availabilityRank(status: NodeAvailability): number {
return status === 'online' ? 0 : status === 'unknown' ? 1 : 2
}
+1 -1
View File
@@ -159,4 +159,4 @@ export interface DwnMessageEntry {
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public' export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
export type Peer = { onion: string; pubkey: string; name?: string; did?: string } export type Peer = { onion: string; pubkey: string; name?: string; did?: string; last_seen?: string }