merge: reuse accepted AI provider setup in publishing worktree
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
|
||||
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
|
||||
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
|
||||
<button v-for="choice in choices" :key="choice.id" class="glass-button rounded-lg px-3 py-3 text-sm text-white" :aria-pressed="mode === choice.id" @click="choose(choice.id)">{{ choice.label }}</button>
|
||||
</div>
|
||||
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
|
||||
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
|
||||
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
|
||||
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
|
||||
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
|
||||
<template v-if="mode === 'openai'">
|
||||
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
|
||||
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
|
||||
</template>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
|
||||
</form>
|
||||
<div v-else-if="mode === 'routstr'" class="space-y-3">
|
||||
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
|
||||
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
|
||||
<RoutstrBudgetSection />
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
|
||||
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
|
||||
</div>
|
||||
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
|
||||
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
|
||||
</BaseModal>
|
||||
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
|
||||
type Choice = 'claude' | 'openai' | 'routstr'
|
||||
interface ProviderStatus {
|
||||
schema: number
|
||||
settings: { provider: 'auto' | 'local' | 'routstr' | 'claude' | 'openai'; openai_model: string }
|
||||
claude_configured: boolean
|
||||
openai_configured: boolean
|
||||
local_ready: boolean | null
|
||||
routstr_remaining_sats: number
|
||||
}
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
|
||||
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
|
||||
const mode = ref<Choice | null>(null)
|
||||
const key = ref('')
|
||||
const model = ref('')
|
||||
const status = ref<ProviderStatus | null>(null)
|
||||
const balance = ref<number | null>(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const funding = ref(false)
|
||||
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
|
||||
let refreshPromise: Promise<ProviderStatus | null> | null = null
|
||||
async function refresh(): Promise<ProviderStatus | null> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
loading.value = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await rpcClient.call<{ value: ProviderStatus | null }>({ method: 'system.settings.get', params: { key: 'ai_provider_status' }, timeout: 8000 })
|
||||
if (response.value?.schema !== 1) throw new Error('AI connection setup needs the matching node update.')
|
||||
status.value = response.value
|
||||
if (!model.value) model.value = response.value.settings.openai_model || ''
|
||||
return response.value
|
||||
} catch {
|
||||
error.value = 'Could not check AI connections. Try again when this node is reachable.'
|
||||
return null
|
||||
} finally { loading.value = false; refreshPromise = null }
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
async function checkNeeded(): Promise<boolean> {
|
||||
const state = await refresh()
|
||||
if (!state) return false // An unavailable status endpoint is not proof of missing configuration.
|
||||
if (state.settings.provider === 'openai') return !state.openai_configured
|
||||
if (state.settings.provider === 'claude') return !state.claude_configured
|
||||
if (state.settings.provider === 'local') return state.local_ready === false
|
||||
if (state.settings.provider === 'routstr' || (state.local_ready === false && !state.claude_configured)) {
|
||||
if (state.routstr_remaining_sats <= 0) return true
|
||||
await refreshBalance()
|
||||
return balance.value === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
async function refreshBalance() {
|
||||
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
|
||||
catch { balance.value = null }
|
||||
}
|
||||
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
|
||||
function close() { key.value = ''; funding.value = false; emit('close') }
|
||||
async function save() {
|
||||
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
|
||||
const provider = mode.value
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
if (key.value.trim()) {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: `${provider}_api_key`, value: key.value.trim() } })
|
||||
key.value = ''
|
||||
if (status.value) status.value[provider === 'claude' ? 'claude_configured' : 'openai_configured'] = true
|
||||
} else if (!configured.value) return
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider, openai_model: model.value.trim() }) } })
|
||||
emit('configured', provider, provider === 'openai' ? model.value.trim() : undefined); close()
|
||||
} catch { error.value = 'Could not save the connection. Check the key and model, then retry.' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function useLocal() {
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'local', openai_model: model.value }) } })
|
||||
emit('configured', 'local'); close()
|
||||
} catch { error.value = 'Could not select local AI. Try again.' }
|
||||
}
|
||||
async function continueRoutstr() {
|
||||
const [state] = await Promise.all([refresh(), refreshBalance()])
|
||||
if (!state || state.routstr_remaining_sats <= 0) { error.value = 'Set a spending allowance before using Routstr.'; return }
|
||||
if (balance.value === null || balance.value <= 0) { error.value = 'Add ecash to this node before using Routstr.'; return }
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'routstr', openai_model: model.value }) } })
|
||||
emit('configured', 'routstr'); close()
|
||||
} catch { error.value = 'Could not select Routstr. Try again.' }
|
||||
}
|
||||
async function finishFunding() { funding.value = false; await refreshBalance() }
|
||||
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
|
||||
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
|
||||
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
|
||||
</script>
|
||||
@@ -147,6 +147,7 @@ const lightning = useLightningRequired()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
|
||||
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
|
||||
note?: string
|
||||
/** Generate an on-chain address immediately when the modal opens */
|
||||
@@ -168,7 +169,7 @@ watch(() => props.show, (open) => {
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
receiveMethod.value = props.initialMethod ?? 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
@@ -342,8 +343,8 @@ async function pollLnClaims() {
|
||||
onUnmounted(stopLnClaimPoll)
|
||||
|
||||
// Fetch the address the first time the operator opens the ecash tab.
|
||||
watch(receiveMethod, (m) => {
|
||||
if (m === 'ecash' && props.show) {
|
||||
watch([receiveMethod, () => props.show], ([m, open]) => {
|
||||
if (m === 'ecash' && open) {
|
||||
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
|
||||
void loadLnAddress()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AIConnectionModal from '../AIConnectionModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
|
||||
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
|
||||
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
|
||||
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
|
||||
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
|
||||
describe('AI connection setup', () => {
|
||||
it('detects absent configuration without treating a failed status query as missing keys', async () => {
|
||||
const w = mountModal()
|
||||
expect(await (w.vm as any).checkNeeded()).toBe(true)
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await (w.vm as any).checkNeeded()).toBe(false)
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'system.settings.set' }))
|
||||
w.unmount()
|
||||
})
|
||||
it('sends a key only to private settings, clears the field, and selects the persisted model', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'OpenAI API').trigger('click')
|
||||
await w.get('#ai-connection-key').setValue('test-private-key')
|
||||
await w.get('#ai-connection-model').setValue('test-chat-model')
|
||||
await w.get('form').trigger('submit'); await flushPromises()
|
||||
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
|
||||
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
|
||||
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
|
||||
w.unmount()
|
||||
})
|
||||
it('clears unsaved keys when switching provider and closing', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'Claude API').trigger('click'); await w.get('#ai-connection-key').setValue('unsaved')
|
||||
await button(w, 'OpenAI API').trigger('click')
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
await w.get('#ai-connection-key').setValue('unsaved-again'); await w.setProps({ show: false }); await w.setProps({ show: true })
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.emitted('configured')).toBeUndefined(); w.unmount()
|
||||
})
|
||||
it('does not reuse an OpenAI model ID when restoring a Routstr connection', async () => {
|
||||
const value = { ...state(), settings: { provider: 'routstr', openai_model: 'previous-openai-model' } }
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ value })
|
||||
const w = mountModal(); await (w.vm as any).syncSelection()
|
||||
expect(w.emitted('configured')).toEqual([['routstr', undefined]])
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('does not authorize Routstr spending from setup when allowance is zero', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'Routstr · sats').trigger('click'); await flushPromises()
|
||||
await button(w, 'Use Routstr').trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('Set a spending allowance')
|
||||
expect(w.emitted('configured')).toBeUndefined()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,17 @@ beforeEach(() => {
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('loads the ecash address on each open when funding starts on the ecash tab', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'funding@minibits.cash' } as never)
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: false, initialMethod: 'ecash' }, attachTo: document.body })
|
||||
await wrapper.setProps({ show: true }); await flushPromises()
|
||||
expect(document.body.textContent).toContain('funding@minibits.cash')
|
||||
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true }); await flushPromises()
|
||||
expect(document.body.textContent).toContain('funding@minibits.cash')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||
let active = false
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
|
||||
@@ -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]),
|
||||
}))
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
"manageDomains": "Manage Domains",
|
||||
"relaysConnected": "{count} connected",
|
||||
"peersKnown": "{count} peer(s) known",
|
||||
"findNodes": "Find Nodes",
|
||||
"findNodes": "Connect with Nodes",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Message (over Tor)",
|
||||
"to": "To",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"manageDomains": "Administrar dominios",
|
||||
"relaysConnected": "{count} conectados",
|
||||
"peersKnown": "{count} par(es) conocido(s)",
|
||||
"findNodes": "Buscar nodos",
|
||||
"findNodes": "Conectar con nodos",
|
||||
"sendMessage": "Enviar mensaje",
|
||||
"sendMessageTitle": "Enviar mensaje (a trav\u00e9s de Tor)",
|
||||
"to": "Para",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AIConnectionModal ref="connectionSetup" :show="showConnectionSetup" @close="showConnectionSetup = false" @configured="providerConfigured" />
|
||||
|
||||
<!-- Loading indicator while iframe loads. pointer-events:none on the
|
||||
wrapper (see <style>) so this never blocks clicks reaching the
|
||||
iframe underneath even while shown; the bounded timeout below
|
||||
@@ -124,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
import AIConnectionModal from '@/components/AIConnectionModal.vue'
|
||||
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
@@ -133,6 +136,13 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
const connectionSetup = ref<InstanceType<typeof AIConnectionModal> | null>(null)
|
||||
const showConnectionSetup = ref(false)
|
||||
function providerConfigured(provider: 'claude' | 'openai' | 'routstr' | 'auto' | 'local', model?: string) {
|
||||
if (!aiuiFrame.value?.contentWindow || !aiuiUrl.value) return
|
||||
aiuiFrame.value.contentWindow.postMessage({ type: 'ai:provider-configured', provider, model }, new URL(aiuiUrl.value, window.location.origin).origin)
|
||||
}
|
||||
onMounted(async () => { if (!IS_DEMO && await connectionSetup.value?.checkNeeded()) showConnectionSetup.value = true })
|
||||
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
|
||||
// loading overlay must never be able to wedge the UI permanently regardless
|
||||
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the
|
||||
@@ -285,15 +295,21 @@ function openAISettings() {
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
if (!aiuiUrl.value || event.source !== aiuiFrame.value?.contentWindow) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
try {
|
||||
const expected = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
if (event.origin !== expected) return
|
||||
} catch { return }
|
||||
if (event.data?.type === 'ai:setup-request') {
|
||||
showConnectionSetup.value = true
|
||||
if (event.data.reason === 'funding') connectionSetup.value?.showRoutstr()
|
||||
return
|
||||
}
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
if (!IS_DEMO) void connectionSetup.value?.syncSelection()
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
// A ⌘K ask that arrived before the handshake is waiting — send it now.
|
||||
flushAsk()
|
||||
@@ -362,6 +378,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
/* Teleported to body, so this is positioned against the viewport, not the
|
||||
chat panel. Sits above the iframe but below the confirm modal — a
|
||||
blocking decision must always win over a passive offer. */
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
|
||||
<PendingRequestsPanel
|
||||
:requests="pendingRequests"
|
||||
:peer-dids="nodes.map(node => node.did)"
|
||||
:polling="pollingHandshake"
|
||||
:busy-id="pendingBusyId"
|
||||
@poll="pollHandshake"
|
||||
@@ -260,6 +261,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'
|
||||
@@ -349,6 +351,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) {
|
||||
@@ -367,6 +370,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,
|
||||
})
|
||||
@@ -572,10 +576,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
|
||||
@@ -832,6 +834,7 @@ onMounted(async () => {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
availabilityNow.value = Date.now()
|
||||
void nodesRes.refresh()
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
:node-count="fleet.nodes.value.length"
|
||||
:online-count="fleet.onlineCount.value"
|
||||
:offline-count="fleet.offlineCount.value"
|
||||
:unknown-count="fleet.unknownCount.value"
|
||||
:fleet-health-pct="fleet.fleetHealthPct.value"
|
||||
:healthy-count="fleet.healthyCount.value"
|
||||
:avg-cpu="fleet.avgCpu.value"
|
||||
|
||||
@@ -74,14 +74,19 @@ function hideSigner(delay = 0) {
|
||||
}
|
||||
|
||||
function sendIdentity(identity: SelectedIdentity) {
|
||||
// Picker entries are Vue reactive objects. postMessage cannot clone a Proxy;
|
||||
// sending it directly closes the picker and then throws, stranding the app
|
||||
// behind the otherwise empty signer frame. Copy only the public fields.
|
||||
parentPost({ type: 'archipelago:signer-identity', identity: {
|
||||
id: identity.id, name: identity.name, did: identity.did,
|
||||
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey,
|
||||
// The picker emits a Vue reactive object. Browser postMessage cannot clone
|
||||
// that proxy: sending it closes the picker, then throws before the broker
|
||||
// can hide, leaving a blank full-screen surface. Send public scalar fields
|
||||
// explicitly, also keeping any unrelated identity metadata out of the app.
|
||||
const publicIdentity: SelectedIdentity = {
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
did: identity.did,
|
||||
pubkey: identity.pubkey,
|
||||
nostr_pubkey: identity.nostr_pubkey,
|
||||
nostr_npub: identity.nostr_npub,
|
||||
} })
|
||||
}
|
||||
parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity })
|
||||
}
|
||||
|
||||
const bridge = useNostrBridge(getStoredIdentity, {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Chat from '../Chat.vue'
|
||||
|
||||
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
|
||||
|
||||
const routerBackMock = vi.fn()
|
||||
const routerPushMock = vi.fn()
|
||||
const routerReplaceMock = vi.fn()
|
||||
@@ -99,6 +101,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
@@ -125,6 +128,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
@@ -165,6 +169,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://evil.example',
|
||||
}))
|
||||
@@ -177,10 +182,22 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores ready and setup messages from a different same-origin frame', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
for (const type of ['ready', 'ai:setup-request']) window.dispatchEvent(new MessageEvent('message', {
|
||||
source: window, origin: 'http://localhost:5173', data: { type },
|
||||
}))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.chat-loading').exists()).toBe(true)
|
||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
|
||||
const { wrapper, show } = mountChatInKeepAlive()
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://localhost:5173',
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { reactive } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
||||
|
||||
describe('NostrTabSigner visibility', () => {
|
||||
@@ -9,7 +10,7 @@ describe('NostrTabSigner visibility', () => {
|
||||
window.history.replaceState({}, '', '/nostr-signer')
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
|
||||
function parentMessage(data: Record<string, unknown>) {
|
||||
const event = new MessageEvent('message', { data, origin: window.location.origin })
|
||||
@@ -64,4 +65,33 @@ describe('NostrTabSigner visibility', () => {
|
||||
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
|
||||
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
|
||||
})
|
||||
|
||||
it('hands off a reactive picker identity as cloneable public fields and releases the overlay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const sent: Array<Record<string, unknown>> = []
|
||||
// Real cross-frame postMessage performs structured cloning. A normal spy
|
||||
// would miss the browser-only DataCloneError that leaves a grey overlay.
|
||||
vi.spyOn(window.parent, 'postMessage').mockImplementation((message) => {
|
||||
sent.push(structuredClone(message))
|
||||
})
|
||||
const wrapper = shallowMount(NostrTabSigner)
|
||||
parentMessage({type: 'archipelago:signer-init', appId: 'indeedhub', appName: 'IndeeHub'})
|
||||
const selected = reactive({
|
||||
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
|
||||
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test', internal_metadata: 'must-stay-local',
|
||||
})
|
||||
expect(() => structuredClone(selected)).toThrow()
|
||||
wrapper.findComponent(NostrIdentityPicker).vm.$emit('select', selected)
|
||||
await flushPromises()
|
||||
const identityMessage = sent.find(message => message.type === 'archipelago:signer-identity')
|
||||
expect(identityMessage?.identity).toEqual({
|
||||
id: 'identity-a', name: 'Alice', did: 'did:key:test', pubkey: 'public-node-key',
|
||||
nostr_pubkey: 'a'.repeat(64), nostr_npub: 'npub-test',
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(sent).toContainEqual({type: 'archipelago:signer-hide'})
|
||||
expect(wrapper.findComponent(NostrIdentityPicker).props('show')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -24,8 +24,17 @@ type ProviderWindow = Window & {
|
||||
|
||||
describe('nostr-provider identity selection', () => {
|
||||
let providerWindow: ProviderWindow
|
||||
let listeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
listeners = []
|
||||
const add = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => {
|
||||
if (listener) listeners.push([type, listener as EventListenerOrEventListenerObject, options])
|
||||
add(type, listener, options)
|
||||
})
|
||||
sessionStorage.clear()
|
||||
providerWindow = window as ProviderWindow
|
||||
delete providerWindow.__archipelagoNostr
|
||||
delete providerWindow.nostr
|
||||
@@ -36,6 +45,9 @@ describe('nostr-provider identity selection', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const [type, listener, options] of listeners) window.removeEventListener(type, listener, options)
|
||||
vi.clearAllTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(document, 'readyState')
|
||||
@@ -122,6 +134,46 @@ describe('nostr-provider identity selection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reopen after a selected identity when activation survives account restoration', async () => {
|
||||
const { frame, postMessage, signerOrigin } = loadProvider(true)
|
||||
const selected = new MessageEvent('message', {
|
||||
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'selected-key' } },
|
||||
origin: signerOrigin,
|
||||
})
|
||||
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
|
||||
window.dispatchEvent(selected)
|
||||
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('selected-key')
|
||||
postMessage.mockClear()
|
||||
const next = providerWindow.nostr!.getPublicKey()
|
||||
expect(postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
|
||||
)
|
||||
const request = postMessage.mock.calls[0]![0] as { id: number }
|
||||
const response = new MessageEvent('message', {
|
||||
data: { type: 'nostr-response', id: request.id, result: 'selected-key' }, origin: signerOrigin,
|
||||
})
|
||||
Object.defineProperty(response, 'source', { value: frame.contentWindow })
|
||||
window.dispatchEvent(response)
|
||||
await expect(next).resolves.toBe('selected-key')
|
||||
// Explicit account switching remains available after a successful login.
|
||||
void providerWindow.archipelagoNostr!.selectIdentity()
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }), signerOrigin,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mistake reload activation for a new login while restoring a session', () => {
|
||||
sessionStorage.setItem('nostr_token', 'test-session-hint')
|
||||
const { postMessage, signerOrigin } = loadProvider(true)
|
||||
void providerWindow.nostr!.getPublicKey()
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }), signerOrigin,
|
||||
)
|
||||
expect(postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'archipelago:signer-select-identity' }), expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
|
||||
const surface = {
|
||||
expectPageTransition: vi.fn(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!nodes.length" class="text-white/40 text-sm py-8 text-center">
|
||||
No nodes reporting. Ensure telemetry is enabled on beta nodes.
|
||||
No fleet nodes yet. Connect a trusted node to see its status.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="fleet-status-dot"
|
||||
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
|
||||
:class="fleetStatus(node.reported_at) === 'unknown' ? 'bg-white/30' : isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
|
||||
></span>
|
||||
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
||||
</div>
|
||||
@@ -49,10 +49,10 @@
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.cpu_pct)"
|
||||
:style="{ width: Math.min(node.cpu_pct, 100) + '%' }"
|
||||
:style="{ width: Math.min(node.cpu_pct ?? 0, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.cpu_pct.toFixed(0) }}%</span>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.cpu_pct) }}</span>
|
||||
</div>
|
||||
<div class="fleet-metric-row">
|
||||
<span class="text-xs text-white/50">RAM</span>
|
||||
@@ -60,10 +60,10 @@
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.mem_pct)"
|
||||
:style="{ width: Math.min(node.mem_pct, 100) + '%' }"
|
||||
:style="{ width: Math.min(node.mem_pct ?? 0, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.mem_pct.toFixed(0) }}%</span>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.mem_pct) }}</span>
|
||||
</div>
|
||||
<div class="fleet-metric-row">
|
||||
<span class="text-xs text-white/50">Disk</span>
|
||||
@@ -71,10 +71,10 @@
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.disk_pct)"
|
||||
:style="{ width: Math.min(node.disk_pct, 100) + '%' }"
|
||||
:style="{ width: Math.min(node.disk_pct ?? 0, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.disk_pct.toFixed(0) }}%</span>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ formatMetric(node.disk_pct) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
<span>{{ node.running_count }}/{{ node.container_count }} containers</span>
|
||||
<span>{{ node.federation_peers }} peers</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40 mt-1">
|
||||
<span>Up {{ formatUptime(node.uptime_secs) }}</span>
|
||||
<span>{{ timeAgo(node.reported_at) }}</span>
|
||||
<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) === 'stale' ? 'Not reporting · last seen ' + timeAgo(node.reported_at) : 'Seen ' + timeAgo(node.reported_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type FleetNode, type SortOption, SORT_OPTIONS,
|
||||
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
|
||||
isOnline, fleetStatus, formatMetric, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
|
||||
} from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -5,43 +5,45 @@
|
||||
<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>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
|
||||
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
|
||||
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
|
||||
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} reporting without alerts</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ formatMetric(avgCpu, 1) }}</p>
|
||||
<p class="text-xs text-white/40">reporting online nodes</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ formatMetric(avgMem, 1) }}</p>
|
||||
<p class="text-xs text-white/40">reporting online nodes</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ formatMetric(avgDisk, 1) }}</p>
|
||||
<p class="text-xs text-white/40">reporting online nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { healthTextClass } from './useFleetData'
|
||||
import { healthTextClass, formatMetric } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodeCount: number
|
||||
onlineCount: number
|
||||
offlineCount: number
|
||||
unknownCount: number
|
||||
fleetHealthPct: number
|
||||
healthyCount: number
|
||||
avgCpu: number
|
||||
avgMem: number
|
||||
avgDisk: number
|
||||
avgCpu: number | null
|
||||
avgMem: number | null
|
||||
avgDisk: number | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FleetNodeGrid from '../FleetNodeGrid.vue'
|
||||
import FleetOverviewCards from '../FleetOverviewCards.vue'
|
||||
import { normalizeFleetNode } from '../useFleetData'
|
||||
|
||||
describe('fleet unavailable readings', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows an unavailable reading differently from a real zero and explains offline status', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
const nodes = [normalizeFleetNode({node_id: 'offline', reported_at: '2026-06-08T12:00:00Z', cpu_pct: 0}), normalizeFleetNode({node_id: 'unknown'})]
|
||||
const wrapper = mount(FleetNodeGrid, {
|
||||
props: {nodes, sortedNodes: nodes, sortBy: 'status', selectedNodeId: null},
|
||||
global: {mocks: {$ver: (v: string) => v}},
|
||||
})
|
||||
const cards = wrapper.findAll('.fleet-node-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]!.text()).toContain('0%')
|
||||
expect(cards[0]!.text()).toContain('—')
|
||||
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')
|
||||
expect(wrapper.text()).not.toContain('NaN')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders empty averages without claiming zero load', () => {
|
||||
const wrapper = mount(FleetOverviewCards, {props: {
|
||||
nodeCount: 1, onlineCount: 0, offlineCount: 0, unknownCount: 1,
|
||||
fleetHealthPct: 0, healthyCount: 0, avgCpu: null, avgMem: null, avgDisk: null,
|
||||
}})
|
||||
expect(wrapper.text()).toContain('1 unknown')
|
||||
expect(wrapper.findAll('.monitoring-stat-card').slice(2).map(card => card.findAll('p').map(p => p.text()).join(' '))).toEqual([
|
||||
'Avg CPU — reporting online nodes', 'Avg RAM — reporting online nodes', 'Avg Disk — reporting online nodes',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
averageMetric,
|
||||
fleetStatus,
|
||||
formatMetric,
|
||||
timeAgo,
|
||||
fleetNodeDisplayName,
|
||||
fleetNodeSubtitle,
|
||||
isOnline,
|
||||
@@ -31,6 +35,7 @@ function node(id: string, reportedAt: string): FleetNode {
|
||||
}
|
||||
|
||||
describe('fleet data helpers', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('treats nodes reported within 30 minutes as online', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
@@ -41,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 = [
|
||||
@@ -51,8 +56,8 @@ describe('fleet data helpers', () => {
|
||||
]
|
||||
|
||||
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
|
||||
'online-new',
|
||||
'online-old',
|
||||
'online-new',
|
||||
'offline',
|
||||
])
|
||||
|
||||
@@ -79,9 +84,9 @@ describe('fleet data helpers', () => {
|
||||
expect(normalized.node_name).toBeNull()
|
||||
expect(normalized.hostname).toBeNull()
|
||||
expect(normalized.server_url).toBeNull()
|
||||
expect(normalized.cpu_pct).toBe(0)
|
||||
expect(normalized.mem_pct).toBe(0)
|
||||
expect(normalized.disk_pct).toBe(0)
|
||||
expect(normalized.cpu_pct).toBeNull()
|
||||
expect(normalized.mem_pct).toBeNull()
|
||||
expect(normalized.disk_pct).toBeNull()
|
||||
expect(normalized.containers).toEqual([])
|
||||
expect(normalized.recent_alerts).toEqual([])
|
||||
})
|
||||
@@ -116,3 +121,47 @@ describe('fleet data helpers', () => {
|
||||
expect(normalizeNodeHistoryResponse({})).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('fleet missing data and clock boundaries', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('does not turn malformed, missing or future timestamps into online status', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
for (const value of ['', 'broken', '2027-01-01T00:00:00Z']) {
|
||||
expect(fleetStatus(value)).toBe('unknown')
|
||||
expect(isOnline(value)).toBe(false)
|
||||
expect(timeAgo(value)).toBe('Unknown')
|
||||
}
|
||||
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', 'unknown', 'future', 'old'])
|
||||
})
|
||||
it('keeps valid zero separate from unavailable and invalid measurements', () => {
|
||||
expect(normalizeFleetNode({cpu_pct: 0}).cpu_pct).toBe(0)
|
||||
for (const value of [null, undefined, NaN, Infinity, -1, 101]) {
|
||||
const result = normalizeFleetNode({cpu_pct: value, mem_pct: value, disk_pct: value})
|
||||
expect(result.cpu_pct).toBeNull()
|
||||
expect(result.mem_pct).toBeNull()
|
||||
expect(result.disk_pct).toBeNull()
|
||||
}
|
||||
expect(formatMetric(null)).toBe('—')
|
||||
expect(formatMetric(0)).toBe('0%')
|
||||
expect(formatMetric(25.25, 1)).toBe('25.3%')
|
||||
})
|
||||
it('averages only real measurements from reporting online nodes', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
const first = node('first', '2026-06-10T11:59:00Z')
|
||||
first.cpu_pct = 0
|
||||
const second = node('second', first.reported_at)
|
||||
second.cpu_pct = 50
|
||||
const missing = node('missing', first.reported_at)
|
||||
missing.cpu_pct = null
|
||||
const offline = node('offline', '2026-06-01T00:00:00Z')
|
||||
offline.cpu_pct = 100
|
||||
expect(averageMetric([first, second, missing, offline], 'cpu_pct')).toBe(25)
|
||||
expect(averageMetric([missing, offline], 'cpu_pct')).toBeNull()
|
||||
expect(averageMetric([], 'cpu_pct')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,11 @@ export interface FleetNode {
|
||||
hostname?: string | null
|
||||
server_url?: string | null
|
||||
version: string
|
||||
uptime_secs: number
|
||||
uptime_secs: number | null
|
||||
cpu_cores: number
|
||||
cpu_pct: number
|
||||
mem_pct: number
|
||||
disk_pct: number
|
||||
cpu_pct: number | null
|
||||
mem_pct: number | null
|
||||
disk_pct: number | null
|
||||
container_count: number
|
||||
running_count: number
|
||||
federation_peers: number
|
||||
@@ -43,7 +43,8 @@ export type SortOption = 'status' | 'last-seen' | 'name'
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
export function formatUptime(secs: number): string {
|
||||
export function formatUptime(secs: number | null): string {
|
||||
if (secs === null) return 'Unavailable'
|
||||
if (secs < 60) return `${secs}s`
|
||||
const days = Math.floor(secs / 86400)
|
||||
const hours = Math.floor((secs % 86400) / 3600)
|
||||
@@ -56,6 +57,7 @@ export function formatUptime(secs: number): string {
|
||||
export function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
if (!Number.isFinite(then) || then > now + 60_000) return 'Unknown'
|
||||
const diffMs = now - then
|
||||
if (diffMs < 0) return 'just now'
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
@@ -68,18 +70,34 @@ export function timeAgo(dateStr: string): string {
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
export function isOnline(reportedAt: string): boolean {
|
||||
const thirtyMinMs = 30 * 60 * 1000
|
||||
return Date.now() - new Date(reportedAt).getTime() < thirtyMinMs
|
||||
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' : 'stale'
|
||||
}
|
||||
|
||||
export function healthBarClass(pct: number): string {
|
||||
export function isOnline(reportedAt: string): boolean {
|
||||
return fleetStatus(reportedAt) === 'online'
|
||||
}
|
||||
|
||||
export function formatMetric(value: number | null, digits = 0): string {
|
||||
return value === null ? '—' : `${value.toFixed(digits)}%`
|
||||
}
|
||||
|
||||
export function averageMetric(nodes: FleetNode[], field: 'cpu_pct' | 'mem_pct' | 'disk_pct'): number | null {
|
||||
const values = nodes.filter(n => isOnline(n.reported_at)).map(n => n[field]).filter((v): v is number => v !== null && Number.isFinite(v))
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
|
||||
}
|
||||
|
||||
export function healthBarClass(pct: number | null): string {
|
||||
if (pct === null) return ''
|
||||
if (pct >= 85) return 'monitoring-bar-danger'
|
||||
if (pct >= 60) return 'monitoring-bar-warn'
|
||||
return 'monitoring-bar-ok'
|
||||
}
|
||||
|
||||
export function healthTextClass(pct: number): string {
|
||||
export function healthTextClass(pct: number | null): string {
|
||||
if (pct === null) return ''
|
||||
if (pct >= 85) return 'fleet-text-danger'
|
||||
if (pct >= 60) return 'fleet-text-warn'
|
||||
return ''
|
||||
@@ -132,19 +150,21 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
||||
{ label: 'Name', value: 'name' },
|
||||
]
|
||||
|
||||
function reportTime(node: FleetNode): number {
|
||||
return fleetStatus(node.reported_at) === 'unknown' ? 0 : new Date(node.reported_at).getTime()
|
||||
}
|
||||
|
||||
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
|
||||
const sorted = [...nodes]
|
||||
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 new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
||||
const rank = { online: 0, unknown: 1, stale: 2 }
|
||||
return rank[fleetStatus(a.reported_at)] - rank[fleetStatus(b.reported_at)]
|
||||
})
|
||||
break
|
||||
case 'last-seen':
|
||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||
sorted.sort((a, b) => reportTime(b) - reportTime(a))
|
||||
break
|
||||
case 'name':
|
||||
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
|
||||
@@ -153,6 +173,15 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
|
||||
return sorted
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
function percentageOrNull(value: unknown): number | null {
|
||||
const number = numberOrNull(value)
|
||||
return number !== null && number <= 100 ? number : null
|
||||
}
|
||||
|
||||
function numberOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
@@ -164,17 +193,17 @@ export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
||||
hostname: typeof node.hostname === 'string' ? node.hostname : null,
|
||||
server_url: typeof node.server_url === 'string' ? node.server_url : null,
|
||||
version: typeof node.version === 'string' ? node.version : 'unknown',
|
||||
uptime_secs: numberOrZero(node.uptime_secs),
|
||||
uptime_secs: numberOrNull(node.uptime_secs),
|
||||
cpu_cores: numberOrZero(node.cpu_cores),
|
||||
cpu_pct: numberOrZero(node.cpu_pct),
|
||||
mem_pct: numberOrZero(node.mem_pct),
|
||||
disk_pct: numberOrZero(node.disk_pct),
|
||||
cpu_pct: percentageOrNull(node.cpu_pct),
|
||||
mem_pct: percentageOrNull(node.mem_pct),
|
||||
disk_pct: percentageOrNull(node.disk_pct),
|
||||
container_count: numberOrZero(node.container_count),
|
||||
running_count: numberOrZero(node.running_count),
|
||||
federation_peers: numberOrZero(node.federation_peers),
|
||||
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
|
||||
containers: Array.isArray(node.containers) ? node.containers : [],
|
||||
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
|
||||
reported_at: typeof node.reported_at === 'string' ? node.reported_at : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +224,7 @@ type FleetCache = {
|
||||
sortBy: SortOption
|
||||
}
|
||||
|
||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
|
||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v2'
|
||||
|
||||
function readFleetCache(): Partial<FleetCache> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
@@ -246,28 +275,20 @@ export function useFleetData() {
|
||||
// --- Computed ---
|
||||
|
||||
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
|
||||
const offlineCount = computed(() => nodes.value.length - onlineCount.value)
|
||||
const healthyCount = computed(() => nodes.value.filter(n => n.recent_alerts.length === 0).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)
|
||||
|
||||
const fleetHealthPct = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return Math.round((healthyCount.value / nodes.value.length) * 100)
|
||||
})
|
||||
|
||||
const avgCpu = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.cpu_pct, 0) / nodes.value.length
|
||||
})
|
||||
const avgCpu = computed(() => averageMetric(nodes.value, 'cpu_pct'))
|
||||
|
||||
const avgMem = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.mem_pct, 0) / nodes.value.length
|
||||
})
|
||||
const avgMem = computed(() => averageMetric(nodes.value, 'mem_pct'))
|
||||
|
||||
const avgDisk = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.disk_pct, 0) / nodes.value.length
|
||||
})
|
||||
const avgDisk = computed(() => averageMetric(nodes.value, 'disk_pct'))
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
@@ -525,7 +546,7 @@ export function useFleetData() {
|
||||
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
||||
onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct,
|
||||
avgCpu, avgMem, avgDisk, sortedNodes, allAppIds,
|
||||
nodeHistoryLabels, nodeHistoryCpuDatasets, nodeHistoryMemDatasets, nodeHistoryDiskDatasets,
|
||||
refreshAll, selectNode, toggleAutoRefresh, exportFleetData,
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
:dhtDid="dhtDid"
|
||||
:dhtDidCopied="dhtDidCopied"
|
||||
:publishingDht="publishingDht"
|
||||
:walletConnected="walletConnected"
|
||||
:connectingWallet="connectingWallet"
|
||||
:nostrRelayStats="nostrRelaysRef?.nostrRelayStats ?? null"
|
||||
:connectedNodesCount="connectedNodesRef?.peers?.length ?? 0"
|
||||
:detectedHwWallets="detectedHwWallets"
|
||||
@@ -23,7 +21,6 @@
|
||||
@copyDhtDid="copyDhtDid"
|
||||
@refreshDhtDid="refreshDhtDid"
|
||||
@publishDhtDid="publishDhtDid"
|
||||
@connectWallet="connectWallet"
|
||||
@manageRelays="nostrRelaysRef?.openRelaysModal()"
|
||||
/>
|
||||
|
||||
@@ -90,7 +87,7 @@ let web5AnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onActivated, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
@@ -288,41 +285,9 @@ async function copyDidDocument() {
|
||||
setTimeout(() => { didDocCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
// --- Wallet / LND Balances ---
|
||||
// Cached: balances/transactions paint instantly on revisit and revalidate
|
||||
// behind the cached value; errors keep the last-known data.
|
||||
const lndInfoRes = useCachedResource<{
|
||||
balance_sats: number
|
||||
channel_balance_sats: number
|
||||
synced_to_chain: boolean
|
||||
}>({
|
||||
key: 'web5.lnd-info',
|
||||
fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
|
||||
persist: false, // wallet balance — must never land in sessionStorage (T-02-01)
|
||||
})
|
||||
// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
|
||||
const walletManuallyDisconnected = ref(false)
|
||||
const walletConnected = computed(() =>
|
||||
!walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
|
||||
const connectingWallet = ref(false)
|
||||
// Ecash/transaction/balance display lives in the hidden wallet card — when it
|
||||
// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
|
||||
// here rather than reviving the old eager loaders.
|
||||
|
||||
// Hardware wallets
|
||||
const detectedHwWallets = ref<HwWalletDevice[]>([])
|
||||
|
||||
async function connectWallet() {
|
||||
if (walletConnected.value) {
|
||||
walletManuallyDisconnected.value = true
|
||||
} else {
|
||||
connectingWallet.value = true
|
||||
walletManuallyDisconnected.value = false
|
||||
await lndInfoRes.refresh()
|
||||
connectingWallet.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detectHardwareWallets() {
|
||||
try {
|
||||
const res = await rpcClient.detectUsbDevices()
|
||||
@@ -332,10 +297,6 @@ async function detectHardwareWallets() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh wallet data every 30s while mounted (B5 will move this to
|
||||
// WS-push invalidation; the store dedups overlapping refreshes).
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Every-entry: none of these child components use useCachedResource
|
||||
// internally (confirmed by reading Web5ConnectedNodes.vue/Web5Identities.vue/
|
||||
// Web5NodeVisibility.vue/Web5NostrRelays.vue — all are plain functions
|
||||
@@ -353,20 +314,12 @@ function armWeb5Live() {
|
||||
// credentialsRef.value?.loadCredentials() // hidden for now
|
||||
// sharedContentRef.value?.loadContentItems() // hidden for now
|
||||
|
||||
// Wallet/profits resources fetch themselves on first use (and skip the
|
||||
// Profits resources fetch themselves on first use (and skip the
|
||||
// fetch entirely when the cached value is still fresh).
|
||||
detectHardwareWallets()
|
||||
|
||||
// Shared content loaded by the component itself via expose
|
||||
// The SharedContent component manages its own loadContentItems
|
||||
|
||||
// Only-while-visible: the 30s force-refresh interval bypasses
|
||||
// useCachedResource's own TTL gate for lndInfoRes, so it must not keep
|
||||
// running while Web5 is off screen.
|
||||
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = setInterval(() => {
|
||||
void lndInfoRes.refresh()
|
||||
}, 30000)
|
||||
}
|
||||
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
|
||||
// component's first mount, which would otherwise double every load in
|
||||
@@ -400,17 +353,4 @@ onMounted(() => {
|
||||
armWeb5Live()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (walletRefreshInterval) {
|
||||
clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (walletRefreshInterval) {
|
||||
clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
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 }
|
||||
}
|
||||
|
||||
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").
|
||||
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
|
||||
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
|
||||
// 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() {
|
||||
@@ -437,23 +454,20 @@ 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 {
|
||||
loadingRequests.value = false
|
||||
}
|
||||
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) {
|
||||
@@ -461,7 +475,7 @@ async function acceptRequest(requestId: string) {
|
||||
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,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="web5-card-actions-top items-center gap-2">
|
||||
<RouterLink to="/dashboard/server/federation" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Find Nodes
|
||||
Connect with Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Fleet
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
<div class="web5-card-actions-bottom-grid mt-6 grid-cols-2 gap-3">
|
||||
<RouterLink to="/dashboard/server/federation" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Find Nodes
|
||||
Connect with Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Fleet
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
|
||||
<!-- Networking Profits -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
@@ -113,27 +113,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Connection -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ t('web5.wallet') }}</p>
|
||||
<p class="text-xs text-white/60">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('connectWallet')"
|
||||
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="connectingWallet"
|
||||
>
|
||||
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relay Status -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
@@ -225,8 +204,6 @@ defineProps<{
|
||||
dhtDid: string | null
|
||||
dhtDidCopied: boolean
|
||||
publishingDht: boolean
|
||||
walletConnected: boolean
|
||||
connectingWallet: boolean
|
||||
nostrRelayStats: NostrRelayStatsData | null
|
||||
connectedNodesCount: number
|
||||
detectedHwWallets: HwWalletDevice[]
|
||||
@@ -239,7 +216,6 @@ defineEmits<{
|
||||
copyDhtDid: []
|
||||
refreshDhtDid: []
|
||||
publishDhtDid: []
|
||||
connectWallet: []
|
||||
manageRelays: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -34,13 +34,13 @@ function mountFederation() {
|
||||
}
|
||||
|
||||
describe('Web5Federation', () => {
|
||||
it('surfaces Find Nodes and Fleet routes', () => {
|
||||
it('surfaces Connect with Nodes and Fleet routes', () => {
|
||||
const wrapper = mountFederation()
|
||||
|
||||
const links = wrapper.findAll('a').map(link => link.attributes('href'))
|
||||
expect(links).toContain('/dashboard/server/federation')
|
||||
expect(links).toContain('/dashboard/fleet')
|
||||
expect(wrapper.text()).toContain('Find Nodes')
|
||||
expect(wrapper.text()).toContain('Connect with Nodes')
|
||||
expect(wrapper.text()).toContain('Fleet')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 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