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
Status: queued by the operator on 2026-10-05. Complete the current release first;
these requests do not silently expand its artifact scope. No implementation or
acceptance is claimed by this backlog.
Status: implementation and qualification in progress. 1.9.0-alpha was published
separately; these follow-ups are not in its immutable artifacts. The list below
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
@@ -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.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">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>
</div>
<!-- 2D/3D projection toggle — tweens the camera between the flat radial
@@ -81,6 +82,7 @@ export interface MapNode {
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
statusLabel?: string
app_count: number
is_self: boolean
}
@@ -162,6 +164,7 @@ interface PeerVis {
dying: boolean
el: SVGGElement
label: SVGTextElement
statusText: SVGTextElement
linkEl: SVGPathElement | null
dots: GlobeDot[]
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,
})
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')
title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}`
g.append(label, title)
title.textContent = `${node.did}\nApps: ${node.app_count}\n${statusText.textContent}`
g.setAttribute('aria-label', `${node.label}: ${statusText.textContent}`)
g.append(label, statusText, title)
g.addEventListener('click', () => emit('select', node.did))
nodesLayer!.appendChild(g)
@@ -483,6 +492,7 @@ function createPeer(node: MapNode, slot: { angle: number; ringRadius: number },
dying: false,
el: g,
label,
statusText,
linkEl,
dots,
radiusPx: r,
@@ -672,7 +682,9 @@ function applyGraph() {
existing.node = node
if (existing.label.textContent !== node.label) existing.label.textContent = node.label
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)
}
@@ -1249,7 +1261,7 @@ function initialBuild() {
}
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]),
requests: (props.requests ?? []).map(r => [r.id, r.label]),
}))
@@ -6,6 +6,7 @@ import { useWeb5BadgeStore } from '../web5Badge'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
},
}))
@@ -75,4 +76,25 @@ describe('useWeb5BadgeStore', () => {
await store.refresh()
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)
async function refresh() {
try {
const res = await rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' })
pendingRequestCount.value = res.requests?.length ?? 0
} catch (e) {
if (import.meta.env.DEV) console.warn('Badge refresh failed — best-effort', e)
const results = await Promise.allSettled([
rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' }),
rpcClient.federationListPendingRequests(),
])
// 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
:requests="pendingRequests"
:peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@@ -259,6 +260,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { validContact, lastContactLabel } from './web5/nodeAvailability'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
@@ -347,6 +349,7 @@ const selfDid = ref<string>((() => {
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
})())
const availabilityNow = ref(Date.now())
const mapNodes = computed(() => {
const result = []
if (selfDid.value) {
@@ -365,6 +368,7 @@ const mapNodes = computed(() => {
label: nodeName(node),
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
online: isOnlineCheck(node),
statusLabel: isOnlineCheck(node) ? 'Online' : lastContactLabel(node.last_seen, availabilityNow.value),
app_count: node.last_state?.apps?.length ?? 0,
is_self: false,
})
@@ -570,10 +574,8 @@ async function cancelPending(id: string) {
}
function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false
const lastSeen = new Date(node.last_seen).getTime()
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
return lastSeen > tenMinutesAgo
const lastSeen = validContact(node.last_seen, availabilityNow.value)
return lastSeen !== undefined && availabilityNow.value - lastSeen < 10 * 60 * 1000
}
/** Explicit reload (mutations, retry): surfaces a load failure in the error
@@ -830,6 +832,7 @@ onMounted(async () => {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
availabilityNow.value = Date.now()
void nodesRes.refresh()
loadPendingRequests()
}, 5000)
@@ -1,12 +1,12 @@
<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>
<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">
Inbound requests await your approval. Outbound requests show what you've sent.
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
to Trusted manually if you want them to receive state syncs.
Connections become <span class="text-orange-300">Observer</span> peers.
Approval delivery retries automatically while the other node connects.
</p>
</div>
<button
@@ -80,6 +80,7 @@ const props = defineProps<{
requests: PendingPeerRequest[]
polling: boolean
busyId: string | null
peerDids?: string[]
}>()
defineEmits<{
@@ -96,6 +97,7 @@ const visibleRequests = computed(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return props.requests.filter((r) => {
if (r.state === 'pending' || r.state === 'sent') return true
if (r.state === 'approved' && !props.peerDids?.includes(r.from_did)) return true
const ts = new Date(r.received_at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
@@ -104,14 +106,20 @@ const visibleRequests = computed(() => {
function badgeClass(r: PendingPeerRequest): string {
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
if (r.state === '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'
return 'bg-white/10 text-white/50'
}
function badgeLabel(r: PendingPeerRequest): string {
if (r.outbound && r.state === 'sent') return 'Sent'
if (r.outbound && r.state === 'approved') return 'They approved'
if (r.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) return r.state
if (r.state === 'pending') return 'Inbound'
@@ -126,7 +134,7 @@ function shortNpub(npub: string): string {
function relative(iso: string): string {
const ts = new Date(iso).getTime()
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)
if (sec < 60) return `${sec}s ago`
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 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>{{ 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>
@@ -5,7 +5,7 @@
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
<p class="text-xs text-white/40">
<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>
</p>
</div>
@@ -18,7 +18,7 @@ describe('fleet unavailable readings', () => {
expect(cards).toHaveLength(2)
expect(cards[0]!.text()).toContain('0%')
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()).not.toContain('0%')
expect(cards[1]!.text()).toContain('Uptime unavailable')
@@ -46,7 +46,7 @@ describe('fleet data helpers', () => {
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.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const nodes = [
@@ -56,8 +56,8 @@ describe('fleet data helpers', () => {
]
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
'online-new',
'online-old',
'online-new',
'offline',
])
@@ -133,9 +133,9 @@ describe('fleet missing data and clock boundaries', () => {
expect(isOnline(value)).toBe(false)
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(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', () => {
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`
}
export function fleetStatus(reportedAt: string): 'online' | 'offline' | 'unknown' {
export function fleetStatus(reportedAt: string): 'online' | 'stale' | 'unknown' {
const age = Date.now() - new Date(reportedAt).getTime()
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 {
@@ -159,10 +159,8 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
switch (sortBy) {
case 'status':
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? -1 : 1
return reportTime(b) - reportTime(a)
const rank = { online: 0, unknown: 1, stale: 2 }
return rank[fleetStatus(a.reported_at)] - rank[fleetStatus(b.reported_at)]
})
break
case 'last-seen':
@@ -277,7 +275,7 @@ export function useFleetData() {
// --- Computed ---
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 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>
<!-- 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
@click="nodesContainerTab = 'trusted'"
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'"
>
{{ t('web5.requests') }}
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</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="ml-1.5 text-xs text-orange-400">({{ requestCount }})</span>
<span v-if="requestCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
</div>
<p v-if="peersError && nodesContainerTab !== 'requests'" role="status" class="text-xs text-amber-200 mb-2">{{ peersError }}</p>
<!-- 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-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
@@ -65,16 +66,16 @@
{{ t('common.loading') }}
</div>
<div
v-for="p in peers"
v-for="p in sortedPeers"
:key="p.pubkey"
@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"
>
<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">
<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>
<button
@@ -98,16 +99,16 @@
{{ t('common.loading') }}
</div>
<div
v-for="p in observers"
v-for="p in sortedObservers"
:key="p.pubkey"
@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"
>
<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">
<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>
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
@@ -118,10 +119,17 @@
<!-- 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-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') }}
</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') }}
</div>
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
@@ -231,13 +239,14 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
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 { formatMessageTime } from './utils'
import type { Peer, ConnectionRequest } from './types'
@@ -246,13 +255,13 @@ const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
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 = {
peers: Peer[]
observers: Peer[]
peerReachable: Record<string, boolean>
peerReachable: Record<string, ReachabilitySample>
connectionRequests: ConnectionRequest[]
}
@@ -289,8 +298,27 @@ const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const peerReachableLocal = ref<Record<string, ReachabilitySample>>(cached.peerReachable ?? {})
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
const showSendMessageModal = ref(false)
@@ -309,6 +337,10 @@ const sendMessageSuccess = ref('')
// Connection requests
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 processingRequestId = ref<string | null>(null)
@@ -329,13 +361,14 @@ function federationNodeToPeer(node: FederationNode): Peer {
onion: node.onion,
pubkey: node.pubkey,
did: node.did,
last_seen: node.last_seen,
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
}
}
function switchToRequestsTab() {
nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
if (!loadingRequests.value) {
loadConnectionRequests()
}
}
@@ -346,74 +379,58 @@ function refreshActiveTab() {
else void loadPeers()
}
function persistCache() {
writeConnectedNodesCache({ peers: peers.value, observers: observers.value, peerReachable: peerReachableLocal.value, connectionRequests: connectionRequests.value })
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
if (loadingPeers.value) return
const generation = ++peerGeneration
loadingPeers.value = true
try {
// Independent RPCs — fetched together (serialized they stacked two full
// mesh round-trips before anything rendered).
const [res, fedSettled] = await Promise.all([
rpcClient.listPeers(),
rpcClient.federationListNodes().catch(() => null),
])
const peerList = res.peers || []
const observerList: Peer[] = []
try {
const fedNodes = fedSettled?.nodes || []
for (const n of fedNodes) {
if (!n.onion || n.trust_level === 'untrusted') {
continue
peersError.value = ''
let direct: Peer[] | undefined
let federated: FederationNode[] | undefined
function applyLists() {
if (generation !== peerGeneration) return
const trusted = direct ? [...direct] : [...peers.value]
const observerList: Peer[] = federated ? [] : [...observers.value]
for (const node of federated ?? []) {
if (!node.onion || node.trust_level === 'untrusted') continue
const list = node.trust_level === 'observer' ? observerList : trusted
const peer = federationNodeToPeer(node)
const index = list.findIndex(p => p.did === node.did || p.onion === node.onion || (p.pubkey && p.pubkey === node.pubkey))
if (index < 0) list.push(peer)
else list[index] = { ...list[index], ...peer }
}
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
peers.value = trusted
observers.value = observerList
// The list is ready — render it and re-enable Refresh NOW. The
// 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").
persistCache()
}
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
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
// 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),
}
}),
).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 = []
persistCache()
}
} finally {
loadingPeers.value = false
}
void Promise.all(Array.from({ length: Math.min(4, pending.length) }, probe))
}
async function sendMessage() {
@@ -437,31 +454,28 @@ async function sendMessage() {
}
async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0
if (loadingRequests.value) return
loadingRequests.value = true
try {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch {
if (!hadRequests) connectionRequests.value = []
} finally {
requestError.value = ''
const results = await Promise.allSettled([
rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' }).then(res => { connectionRequests.value = res.requests || [] }),
rpcClient.federationListPendingRequests().then(res => {
nostrRequests.value = (res.requests || []).filter(request => request.state === 'pending' || request.state === 'sent' ||
(request.state === 'approved' && ![...peers.value, ...observers.value].some(peer => peer.did === request.from_did)))
}),
])
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
persistCache()
loadingRequests.value = false
}
}
async function acceptRequest(requestId: string) {
processingRequestId.value = requestId
try {
await rpcClient.call({ method: 'network.accept-request', params: { 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({
peers: peers.value,
observers: observers.value,
@@ -482,7 +496,7 @@ async function rejectRequest(requestId: string) {
try {
await rpcClient.call({ method: 'network.reject-request', params: { 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({
peers: peers.value,
observers: observers.value,
@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
import { rpcClient } from '@/api/rpc-client'
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
@@ -15,6 +17,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
@@ -41,6 +44,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
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')
})
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// 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
// growing to fit every row. See:
enableAutoUnmount(afterEach)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
@@ -19,6 +21,7 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
@@ -45,6 +48,7 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes scroll contract (UIFIX-02)', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
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 Peer = { onion: string; pubkey: string; name?: string; did?: string }
export type Peer = { onion: string; pubkey: string; name?: string; did?: string; last_seen?: string }