Show durable connection requests and timestamped node availability
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = trusted
|
||||||
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
|
|
||||||
// disabled for N-offline-peers × 30s ("takes ages").
|
|
||||||
loadingPeers.value = false
|
|
||||||
void Promise.allSettled(
|
|
||||||
[...peers.value, ...observers.value].map(async (p) => {
|
|
||||||
try {
|
|
||||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
|
||||||
peerReachableLocal.value[p.onion] = check.reachable
|
|
||||||
} catch {
|
|
||||||
peerReachableLocal.value[p.onion] = false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
).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
|
|
||||||
}
|
}
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
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
|
||||||
|
// Bound concurrent probes, render each result immediately, and ignore replies
|
||||||
|
// from a superseded refresh. A failed RPC is unknown, not an offline node.
|
||||||
|
const pending = [...new Map([...peers.value, ...observers.value].map(peer => [peer.onion, peer])).values()]
|
||||||
|
async function probe() {
|
||||||
|
while (pending.length && generation === peerGeneration) {
|
||||||
|
const peer = pending.shift()!
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void Promise.all(Array.from({ length: Math.min(4, pending.length) }, probe))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
@@ -437,23 +454,20 @@ 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 = []
|
loadingRequests.value = false
|
||||||
} finally {
|
|
||||||
loadingRequests.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptRequest(requestId: string) {
|
async function acceptRequest(requestId: string) {
|
||||||
@@ -461,7 +475,7 @@ async function acceptRequest(requestId: string) {
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user