diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts
index 39e9f528..e1452a4f 100644
--- a/neode-ui/src/views/discover/curatedApps.ts
+++ b/neode-ui/src/views/discover/curatedApps.ts
@@ -79,7 +79,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
- { id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing. Storefront opens on port 9011; admin dashboard remains on 9010.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
@@ -87,7 +86,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
- { id: 'nextcloud', title: 'Nextcloud', version: '28', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:28`, repoUrl: 'https://github.com/nextcloud/server' },
+ { id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
@@ -121,7 +120,6 @@ export const INSTALLED_ALIASES: Record = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
- saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -191,7 +189,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
const combined = `${id} ${title} ${description}`
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
- if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
+ if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
diff --git a/neode-ui/src/views/federation/DiscoverModal.vue b/neode-ui/src/views/federation/DiscoverModal.vue
index a89c057b..807b6480 100644
--- a/neode-ui/src/views/federation/DiscoverModal.vue
+++ b/neode-ui/src/views/federation/DiscoverModal.vue
@@ -67,6 +67,13 @@
+
+
+
+
+
+ Searching relays...
+
diff --git a/neode-ui/src/views/federation/NodeDetailModal.vue b/neode-ui/src/views/federation/NodeDetailModal.vue
index e6940a6b..843446ee 100644
--- a/neode-ui/src/views/federation/NodeDetailModal.vue
+++ b/neode-ui/src/views/federation/NodeDetailModal.vue
@@ -1,5 +1,5 @@
-
+
Node Details
diff --git a/neode-ui/src/views/federation/NodeList.vue b/neode-ui/src/views/federation/NodeList.vue
index b35fd1d7..d881a6b5 100644
--- a/neode-ui/src/views/federation/NodeList.vue
+++ b/neode-ui/src/views/federation/NodeList.vue
@@ -28,7 +28,7 @@
Your Nodes ({{ trustedNodes.length }})
-
+
diff --git a/neode-ui/src/views/federation/__tests__/DiscoverModal.test.ts b/neode-ui/src/views/federation/__tests__/DiscoverModal.test.ts
new file mode 100644
index 00000000..ecc8e328
--- /dev/null
+++ b/neode-ui/src/views/federation/__tests__/DiscoverModal.test.ts
@@ -0,0 +1,69 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import DiscoverModal from '../DiscoverModal.vue'
+import { rpcClient } from '@/api/rpc-client'
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ handshakeDiscover: vi.fn(),
+ handshakeConnect: vi.fn(),
+ },
+}))
+
+function deferred
() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+function makeNode() {
+ return {
+ nostr_pubkey: 'pubkey-one',
+ nostr_npub: 'npub1abcdefghijklmnopqrstuvwxyz',
+ did: 'did:key:node',
+ version: '1.8-alpha',
+ }
+}
+
+describe('DiscoverModal', () => {
+ it('keeps discoverable nodes visible while relay refresh is pending or fails', async () => {
+ vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
+
+ const wrapper = mount(DiscoverModal, {
+ props: { visible: false, outboundSent: [] },
+ global: {
+ stubs: {
+ Teleport: true,
+ },
+ },
+ })
+ await wrapper.setProps({ visible: true })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('did:key:node')
+ expect(wrapper.text()).toContain('version 1.8-alpha')
+
+ const pending = deferred<{ nodes: [] }>()
+ vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(pending.promise)
+
+ const refresh = (wrapper.vm as unknown as { refresh: () => Promise }).refresh()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('did:key:node')
+ expect(wrapper.text()).toContain('Searching relays...')
+ expect(wrapper.text()).not.toContain('No discoverable nodes found')
+
+ pending.reject(new Error('relay offline'))
+ await refresh
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('did:key:node')
+ expect(wrapper.text()).toContain('relay offline')
+ expect(wrapper.text()).not.toContain('Searching relays...')
+ expect(wrapper.text()).not.toContain('No discoverable nodes found')
+ })
+})
diff --git a/neode-ui/src/views/federation/__tests__/NodeList.test.ts b/neode-ui/src/views/federation/__tests__/NodeList.test.ts
new file mode 100644
index 00000000..859244c2
--- /dev/null
+++ b/neode-ui/src/views/federation/__tests__/NodeList.test.ts
@@ -0,0 +1,32 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import NodeList from '../NodeList.vue'
+import type { FederatedNode } from '../types'
+
+const trustedNode: FederatedNode = {
+ did: 'did:key:z6MkTrustedNode',
+ pubkey: 'trusted-pubkey',
+ onion: 'trusted.onion',
+ trust_level: 'trusted',
+ added_at: '2026-06-10T00:00:00Z',
+ name: 'Trusted Node',
+ last_seen: '2026-06-10T00:00:00Z',
+}
+
+describe('NodeList', () => {
+ it('keeps existing nodes visible during background refresh loading', () => {
+ const wrapper = mount(NodeList, {
+ props: {
+ nodes: [trustedNode],
+ loading: true,
+ error: '',
+ syncResults: [],
+ dwnSyncDotClass: 'bg-green-400',
+ cleaningNodes: false,
+ },
+ })
+
+ expect(wrapper.text()).toContain('Trusted Node')
+ expect(wrapper.text()).not.toContain('Loading nodes...')
+ })
+})
diff --git a/neode-ui/src/views/fleet/FleetNodeDetail.vue b/neode-ui/src/views/fleet/FleetNodeDetail.vue
index 63f1b5c8..7cef0ddc 100644
--- a/neode-ui/src/views/fleet/FleetNodeDetail.vue
+++ b/neode-ui/src/views/fleet/FleetNodeDetail.vue
@@ -28,9 +28,12 @@
-
+
Loading history...
+
+ Refreshing history...
+
CPU History
diff --git a/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts b/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts
new file mode 100644
index 00000000..e330f2c2
--- /dev/null
+++ b/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest'
+import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
+
+function node(id: string, reportedAt: string): FleetNode {
+ return {
+ node_id: id,
+ version: '1.8-alpha',
+ uptime_secs: 60,
+ cpu_cores: 4,
+ cpu_pct: 10,
+ mem_pct: 20,
+ disk_pct: 30,
+ container_count: 2,
+ running_count: 2,
+ federation_peers: 1,
+ recent_alerts: [],
+ containers: [],
+ reported_at: reportedAt,
+ }
+}
+
+describe('fleet data helpers', () => {
+ it('treats nodes reported within 30 minutes as online', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
+
+ expect(isOnline('2026-06-10T11:45:00Z')).toBe(true)
+ expect(isOnline('2026-06-10T11:20:00Z')).toBe(false)
+
+ vi.useRealTimers()
+ })
+
+ it('sorts status with online nodes first, then latest report', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
+ const nodes = [
+ node('offline', '2026-06-10T10:00:00Z'),
+ node('online-old', '2026-06-10T11:45:00Z'),
+ node('online-new', '2026-06-10T11:59:00Z'),
+ ]
+
+ expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
+ 'online-new',
+ 'online-old',
+ 'offline',
+ ])
+
+ vi.useRealTimers()
+ })
+
+ it('sorts by name alphabetically', () => {
+ expect(sortFleetNodes([
+ node('zulu', '2026-06-10T11:59:00Z'),
+ node('alpha', '2026-06-10T11:59:00Z'),
+ ], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
+ })
+
+ it('normalizes older telemetry reports with missing metric and container fields', () => {
+ const normalized = normalizeFleetNode({
+ node_id: 'legacy-node',
+ version: '1.8-alpha',
+ reported_at: '2026-06-10T11:59:00Z',
+ })
+
+ expect(normalized.node_id).toBe('legacy-node')
+ expect(normalized.cpu_pct).toBe(0)
+ expect(normalized.mem_pct).toBe(0)
+ expect(normalized.disk_pct).toBe(0)
+ expect(normalized.containers).toEqual([])
+ expect(normalized.recent_alerts).toEqual([])
+ })
+
+ it('normalizes node history responses from backend entries or legacy history fields', () => {
+ const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
+
+ expect(normalizeNodeHistoryResponse({ entries: [entry] })).toEqual([entry])
+ expect(normalizeNodeHistoryResponse({ history: [entry] })).toEqual([entry])
+ expect(normalizeNodeHistoryResponse({})).toEqual([])
+ })
+})
diff --git a/neode-ui/src/views/fleet/useFleetData.ts b/neode-ui/src/views/fleet/useFleetData.ts
index e47cde16..f03668a9 100644
--- a/neode-ui/src/views/fleet/useFleetData.ts
+++ b/neode-ui/src/views/fleet/useFleetData.ts
@@ -118,6 +118,58 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
{ label: 'Name', value: 'name' },
]
+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()
+ })
+ break
+ case 'last-seen':
+ sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
+ break
+ case 'name':
+ sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
+ break
+ }
+ return sorted
+}
+
+function numberOrZero(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
+}
+
+export function normalizeFleetNode(node: Partial
): FleetNode {
+ return {
+ node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
+ version: typeof node.version === 'string' ? node.version : 'unknown',
+ uptime_secs: numberOrZero(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),
+ 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(),
+ }
+}
+
+export function normalizeNodeHistoryResponse(data: {
+ history?: NodeHistoryEntry[]
+ entries?: NodeHistoryEntry[]
+} | null | undefined): NodeHistoryEntry[] {
+ if (Array.isArray(data?.history)) return data.history
+ if (Array.isArray(data?.entries)) return data.entries
+ return []
+}
+
// --- Composable ---
export function useFleetData() {
@@ -125,6 +177,7 @@ export function useFleetData() {
const errorMessage = ref('')
const nodes = ref([])
const fleetAlerts = ref([])
+ const refreshing = ref(false)
const alertsLoading = ref(false)
const selectedNodeId = ref(null)
const nodeHistory = ref([])
@@ -166,26 +219,7 @@ export function useFleetData() {
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
})
- const sortedNodes = computed(() => {
- const sorted = [...nodes.value]
- switch (sortBy.value) {
- 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()
- })
- break
- case 'last-seen':
- sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
- break
- case 'name':
- sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
- break
- }
- return sorted
- })
+ const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
const allAppIds = computed(() => {
const appSet = new Set()
@@ -227,11 +261,11 @@ export function useFleetData() {
async function fetchFleetStatus() {
try {
- const data = await rpcClient.call<{ nodes: FleetNode[] }>({
+ const data = await rpcClient.call<{ nodes: Partial[] }>({
method: 'telemetry.fleet-status',
})
if (data?.nodes) {
- nodes.value = data.nodes
+ nodes.value = data.nodes.map(normalizeFleetNode)
lastRefreshed.value = new Date().toISOString()
}
} catch (err) {
@@ -259,15 +293,12 @@ export function useFleetData() {
async function fetchNodeHistory(nodeId: string) {
nodeHistoryLoading.value = true
- nodeHistory.value = []
try {
- const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
+ const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
method: 'telemetry.fleet-node-history',
params: { node_id: nodeId },
})
- if (data?.history) {
- nodeHistory.value = data.history
- }
+ nodeHistory.value = normalizeNodeHistoryResponse(data)
} catch {
// Non-critical
} finally {
@@ -277,9 +308,14 @@ export function useFleetData() {
async function refreshAll() {
loading.value = !nodes.value.length
+ refreshing.value = true
errorMessage.value = ''
- await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
- loading.value = false
+ try {
+ await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
+ } finally {
+ loading.value = false
+ refreshing.value = false
+ }
}
function selectNode(nodeId: string) {
@@ -288,7 +324,6 @@ export function useFleetData() {
nodeHistory.value = []
} else {
selectedNodeId.value = nodeId
- fetchNodeHistory(nodeId)
}
}
@@ -367,7 +402,7 @@ export function useFleetData() {
})
return {
- loading, errorMessage, nodes, fleetAlerts, alertsLoading,
+ loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
autoRefresh, lastRefreshed, sortBy, chartWidth,
onlineCount, offlineCount, healthyCount, fleetHealthPct,
diff --git a/neode-ui/src/views/goals/__tests__/goalStepActions.test.ts b/neode-ui/src/views/goals/__tests__/goalStepActions.test.ts
new file mode 100644
index 00000000..c948ef72
--- /dev/null
+++ b/neode-ui/src/views/goals/__tests__/goalStepActions.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { GOALS } from '@/data/goals'
+import { goalStepTargetPath } from '../goalStepActions'
+import type { GoalStep } from '@/types/goals'
+
+describe('goalStepActions', () => {
+ it('routes app-backed steps to their app details page', () => {
+ expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
+ })
+
+ it('routes built-in identity and backup steps to their owning screens', () => {
+ expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
+ expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
+ expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
+ })
+
+ it('keeps passive info steps without a target route', () => {
+ expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
+ })
+
+ it('gives every configure step in the shipped goals a destination', () => {
+ const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
+
+ expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
+ configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
+ )
+ })
+})
+
+function step(overrides: Partial): GoalStep {
+ return {
+ id: 'step',
+ title: 'Step',
+ description: '',
+ action: 'configure',
+ isAutomatic: false,
+ ...overrides,
+ }
+}
diff --git a/neode-ui/src/views/goals/goalStepActions.ts b/neode-ui/src/views/goals/goalStepActions.ts
new file mode 100644
index 00000000..b167d110
--- /dev/null
+++ b/neode-ui/src/views/goals/goalStepActions.ts
@@ -0,0 +1,14 @@
+import type { GoalStep } from '@/types/goals'
+
+const STEP_ROUTE_OVERRIDES: Record = {
+ 'setup-nostr': '/dashboard/web5',
+ 'export-identity': '/dashboard/web5/credentials',
+ 'create-passphrase': '/dashboard/settings',
+ 'create-backup': '/dashboard/settings',
+ 'save-backup': '/dashboard/settings',
+}
+
+export function goalStepTargetPath(step: GoalStep): string | null {
+ if (step.appId) return `/dashboard/apps/${step.appId}`
+ return STEP_ROUTE_OVERRIDES[step.id] ?? null
+}
diff --git a/neode-ui/src/views/home/HomeSystemCard.vue b/neode-ui/src/views/home/HomeSystemCard.vue
index 4a534363..d32f5a32 100644
--- a/neode-ui/src/views/home/HomeSystemCard.vue
+++ b/neode-ui/src/views/home/HomeSystemCard.vue
@@ -4,7 +4,7 @@
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
:class="{ 'home-card-animate': animate }"
- style="--card-stagger: 4"
+ style="--card-stagger: 6"
>
diff --git a/neode-ui/src/views/home/HomeWalletCard.vue b/neode-ui/src/views/home/HomeWalletCard.vue
index 6ec33ea1..d9366d35 100644
--- a/neode-ui/src/views/home/HomeWalletCard.vue
+++ b/neode-ui/src/views/home/HomeWalletCard.vue
@@ -4,7 +4,7 @@
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animate }"
- style="--card-stagger: 3"
+ style="--card-stagger: 4"
>
diff --git a/neode-ui/src/views/home/__tests__/homeRecommendations.test.ts b/neode-ui/src/views/home/__tests__/homeRecommendations.test.ts
new file mode 100644
index 00000000..1954de84
--- /dev/null
+++ b/neode-ui/src/views/home/__tests__/homeRecommendations.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { getHomeRecommendedApps, isMarketplaceAppInstalled } from '../homeRecommendations'
+import type { MarketplaceApp } from '@/views/marketplace/marketplaceData'
+
+const apps: MarketplaceApp[] = [
+ { id: 'vaultwarden', title: 'Vaultwarden', dockerImage: 'vaultwarden:latest' },
+ { id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
+ { id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
+ { id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
+ { id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
+]
+
+describe('homeRecommendations', () => {
+ it('treats installed aliases as installed apps', () => {
+ expect(isMarketplaceAppInstalled('mempool', { 'mempool-web': {} })).toBe(true)
+ expect(isMarketplaceAppInstalled('mempool', { vaultwarden: {} })).toBe(false)
+ })
+
+ it('returns uninstalled installable core and recommended apps for Home', () => {
+ const recommended = getHomeRecommendedApps(apps, { 'mempool-web': {} }, 3)
+
+ expect(recommended.map((app) => app.id)).toEqual([
+ 'bitcoin-knots',
+ 'vaultwarden',
+ ])
+ })
+
+ it('returns no recommendations once matching apps are installed', () => {
+ const recommended = getHomeRecommendedApps(apps, {
+ 'bitcoin-knots': {},
+ 'mempool-web': {},
+ vaultwarden: {},
+ })
+
+ expect(recommended).toEqual([])
+ })
+})
diff --git a/neode-ui/src/views/home/homeRecommendations.ts b/neode-ui/src/views/home/homeRecommendations.ts
new file mode 100644
index 00000000..e60fc8e3
--- /dev/null
+++ b/neode-ui/src/views/home/homeRecommendations.ts
@@ -0,0 +1,33 @@
+import { getAppTier, INSTALLED_ALIASES, type MarketplaceApp } from '@/views/marketplace/marketplaceData'
+
+export type InstalledPackageMap = Record
+
+export function isMarketplaceAppInstalled(
+ appId: string,
+ installedPackages: InstalledPackageMap,
+ aliases: Record = INSTALLED_ALIASES,
+): boolean {
+ if (appId in installedPackages) return true
+ return aliases[appId]?.some((alias) => alias in installedPackages) ?? false
+}
+
+export function getHomeRecommendedApps(
+ apps: MarketplaceApp[],
+ installedPackages: InstalledPackageMap,
+ limit = 3,
+): MarketplaceApp[] {
+ return apps
+ .filter((app) => {
+ if (!app.dockerImage) return false
+ if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
+ const tier = getAppTier(app.id)
+ return tier === 'core' || tier === 'recommended'
+ })
+ .sort((a, b) => {
+ const tierRank = (app: MarketplaceApp) => getAppTier(app.id) === 'core' ? 0 : 1
+ const tierDiff = tierRank(a) - tierRank(b)
+ if (tierDiff !== 0) return tierDiff
+ return (a.title || a.id).localeCompare(b.title || b.id)
+ })
+ .slice(0, limit)
+}
diff --git a/neode-ui/src/views/marketplace/MarketplaceAppCard.vue b/neode-ui/src/views/marketplace/MarketplaceAppCard.vue
index 42d18047..254e0586 100644
--- a/neode-ui/src/views/marketplace/MarketplaceAppCard.vue
+++ b/neode-ui/src/views/marketplace/MarketplaceAppCard.vue
@@ -12,22 +12,16 @@
>
-
{{ app.title }}
{{ tierLabel }}
@@ -153,6 +147,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
+import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
const { t } = useI18n()
@@ -186,7 +181,10 @@ const installProgressMessage = computed(() => {
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
- img.src = '/assets/img/logo-archipelago.svg'
+ if (!img.src.includes(DEFAULT_APP_ICON)) {
+ img.src = DEFAULT_APP_ICON
+ img.dataset.defaultIcon = '1'
+ }
}
diff --git a/neode-ui/src/views/marketplace/MarketplaceFilterModal.vue b/neode-ui/src/views/marketplace/MarketplaceFilterModal.vue
index 2f17182c..61f0debb 100644
--- a/neode-ui/src/views/marketplace/MarketplaceFilterModal.vue
+++ b/neode-ui/src/views/marketplace/MarketplaceFilterModal.vue
@@ -15,7 +15,7 @@
diff --git a/neode-ui/src/views/marketplace/__tests__/MarketplaceAppCard.test.ts b/neode-ui/src/views/marketplace/__tests__/MarketplaceAppCard.test.ts
new file mode 100644
index 00000000..a1d3d55f
--- /dev/null
+++ b/neode-ui/src/views/marketplace/__tests__/MarketplaceAppCard.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createI18n } from 'vue-i18n'
+import MarketplaceAppCard from '../MarketplaceAppCard.vue'
+import type { MarketplaceApp } from '../marketplaceData'
+
+const app: MarketplaceApp = {
+ id: 'btcpay-server',
+ title: 'BTCPay Server',
+ version: '2.0.6',
+ description: 'Self-hosted Bitcoin payments',
+ icon: '',
+ dockerImage: 'btcpayserver/btcpayserver:2.0.6',
+ category: 'commerce',
+ source: 'community',
+}
+
+function mountCard(installed: boolean) {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ common: { install: 'Install', launch: 'Launch', notAvailable: 'Not available' },
+ marketplace: { alreadyInstalled: 'Already installed' },
+ },
+ },
+ })
+
+ return mount(MarketplaceAppCard, {
+ props: {
+ app,
+ index: 0,
+ stagger: false,
+ installed,
+ installing: false,
+ installProgress: undefined,
+ installedState: installed ? 'running' : null,
+ startingUp: false,
+ containersScanned: true,
+ tierLabel: 'recommended',
+ },
+ global: {
+ plugins: [i18n],
+ },
+ })
+}
+
+describe('MarketplaceAppCard', () => {
+ it('shows tier badges before install', () => {
+ const wrapper = mountCard(false)
+ expect(wrapper.find('.tier-badge').exists()).toBe(true)
+ expect(wrapper.text()).toContain('recommended')
+ })
+
+ it('hides tier badges after install', () => {
+ const wrapper = mountCard(true)
+ expect(wrapper.find('.tier-badge').exists()).toBe(false)
+ expect(wrapper.text()).not.toContain('recommended')
+ })
+})
diff --git a/neode-ui/src/views/marketplace/marketplaceData.ts b/neode-ui/src/views/marketplace/marketplaceData.ts
index 1204d8b5..1fc574e8 100644
--- a/neode-ui/src/views/marketplace/marketplaceData.ts
+++ b/neode-ui/src/views/marketplace/marketplaceData.ts
@@ -14,6 +14,7 @@ export interface MarketplaceApp {
manifestUrl?: string
repoUrl?: string
webUrl?: string
+ screenshots?: AppScreenshot[]
category?: string
source?: string
url?: string
@@ -30,6 +31,11 @@ export interface MarketplaceApp {
relayCount?: number
}
+export type AppScreenshot = string | {
+ src: string
+ alt?: string
+}
+
export interface InstallProgress {
id: string
title: string
@@ -47,7 +53,6 @@ export const INSTALLED_ALIASES: Record
= {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
- saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -68,7 +73,7 @@ export const INSTALLED_ALIASES: Record = {
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
- const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
+ const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -90,7 +95,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
return 'money'
}
- if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
+ if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
combined.includes('merchant') || combined.includes('invoice')) {
return 'commerce'
@@ -158,18 +163,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
},
- {
- id: 'saleor',
- title: 'Saleor',
- version: '3.23',
- category: 'commerce',
- description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing.',
- icon: '/assets/img/app-icons/saleor.svg',
- author: 'Saleor',
- dockerImage: 'ghcr.io/saleor/saleor:3.23',
- manifestUrl: undefined,
- repoUrl: 'https://github.com/saleor/saleor'
- },
{
id: 'lnd',
title: 'LND',
@@ -261,11 +254,11 @@ export function getCuratedAppList(): MarketplaceApp[] {
{
id: 'nextcloud',
title: 'Nextcloud',
- version: '28.0',
+ version: '29.0',
description: 'Self-hosted cloud storage and collaboration platform. Your own private cloud.',
icon: '/assets/img/app-icons/nextcloud.webp',
author: 'Nextcloud',
- dockerImage: `${REGISTRY}/nextcloud:28`,
+ dockerImage: `${REGISTRY}/nextcloud:29`,
manifestUrl: undefined,
repoUrl: 'https://github.com/nextcloud/server'
},
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css
index 55048724..7282127d 100644
--- a/neode-ui/src/views/mesh/mesh-styles.css
+++ b/neode-ui/src/views/mesh/mesh-styles.css
@@ -25,12 +25,21 @@
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.mesh-tools-wrapper { display: contents; }
.mesh-tools-tab-bar { display: none; }
-.mesh-columns-wide { display: grid; grid-template-columns: 340px 1fr 1fr; gap: 16px; }
+.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
.mesh-columns-wide .mesh-left { grid-column: 1; width: auto; }
.mesh-columns-wide .mesh-right { display: contents; }
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
-.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow-y: auto; }
+.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
+.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
+.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
+.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
+.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
+.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
+.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
+.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
+.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
+.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
@@ -133,8 +142,8 @@
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
- .mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; }
- .mesh-mobile-tools .mesh-map-panel { min-height: 400px; }
+ .mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
+ .mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
.mesh-chat-back { display: block; }
.mobile-hidden { display: none !important; }
diff --git a/neode-ui/src/views/server/ServerModals.vue b/neode-ui/src/views/server/ServerModals.vue
index 440d49e1..310e6778 100644
--- a/neode-ui/src/views/server/ServerModals.vue
+++ b/neode-ui/src/views/server/ServerModals.vue
@@ -94,7 +94,7 @@
-
+
WiFi Networks
@@ -154,7 +154,7 @@
-
+
DNS Configuration
diff --git a/neode-ui/src/views/server/TorServicesCard.vue b/neode-ui/src/views/server/TorServicesCard.vue
index caca20c3..b2c58f48 100644
--- a/neode-ui/src/views/server/TorServicesCard.vue
+++ b/neode-ui/src/views/server/TorServicesCard.vue
@@ -1,5 +1,5 @@
-
+
@@ -11,17 +11,11 @@
Manage hidden service addresses for your node and apps
-
-
-
-
-
+
+
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
-
-
-
-
+
Add Service
@@ -29,6 +23,13 @@
Loading Tor services...
No Tor services configured
+
+
+
+
+
+ Refreshing Tor services...
+
+
+
+ {{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
+
+
+ Add Service
+
+
diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue
index ce6fed7e..b1029d5a 100644
--- a/neode-ui/src/views/settings/AccountInfoSection.vue
+++ b/neode-ui/src/views/settings/AccountInfoSection.vue
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
+import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const { t } = useI18n()
const store = useAppStore()
@@ -13,6 +14,7 @@ const serverName = computed(() => store.serverName)
const editingServerName = ref(false)
const serverNameDraft = ref('')
const serverNameInput = ref(null)
+const serverNameWarning = ref('')
function startEditServerName() {
serverNameDraft.value = serverName.value
@@ -22,13 +24,17 @@ function startEditServerName() {
async function saveServerName() {
const name = serverNameDraft.value.trim()
+ serverNameWarning.value = ''
if (!name || name === serverName.value) {
editingServerName.value = false
return
}
try {
- await rpcClient.call({ method: 'server.set-name', params: { name } })
+ const result = await rpcClient.call<{ hostname_error?: string | null }>({ method: 'server.set-name', params: { name } })
store.updateServerName(name)
+ if (result.hostname_error) {
+ serverNameWarning.value = `Display name saved, but hostname update failed: ${result.hostname_error}`
+ }
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}
@@ -38,6 +44,7 @@ async function saveServerName() {
// Version & release notes
const version = computed(() => store.serverInfo?.version || '0.0.0')
const showReleaseNotes = ref(false)
+useBodyScrollLock(showReleaseNotes)
// Identity
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
@@ -148,6 +155,7 @@ init()
+
{{ serverNameWarning }}
diff --git a/neode-ui/src/views/settings/BackupSection.vue b/neode-ui/src/views/settings/BackupSection.vue
index b7466c37..4aca26c5 100644
--- a/neode-ui/src/views/settings/BackupSection.vue
+++ b/neode-ui/src/views/settings/BackupSection.vue
@@ -27,6 +27,7 @@ const verifyingBackupId = ref
(null)
const deletingBackupId = ref(null)
const backupStatusMsg = ref('')
const backupStatusType = ref<'success' | 'error'>('success')
+const backupLoadError = ref('')
function formatBackupSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -42,12 +43,15 @@ function showBackupStatus(msg: string, type: 'success' | 'error') {
}
async function loadBackups() {
+ const hadBackups = backupList.value.length > 0
loadingBackups.value = true
+ backupLoadError.value = ''
try {
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
backupList.value = res.backups || []
- } catch {
- backupList.value = []
+ } catch (e: unknown) {
+ backupLoadError.value = e instanceof Error ? e.message : t('settings.backupListFailed')
+ if (!hadBackups) backupList.value = []
} finally {
loadingBackups.value = false
}
@@ -189,6 +193,8 @@ function copyChannelBackup() {
}
loadBackups()
+
+defineExpose({ loadBackups })
@@ -204,9 +210,19 @@ loadBackups()
{{ t('settings.createBackup') }}
-
{{ t('settings.loadingBackups') }}
+
{{ t('settings.loadingBackups') }}
{{ t('settings.noBackups') }}
+
+
+
+
+
+ Refreshing backups...
+
+
+ {{ backupLoadError }}
+
{{ b.description || t('settings.systemBackup') }}
@@ -235,7 +251,7 @@ loadBackups()
-
+
{{ t('settings.createEncryptedBackup') }}
@@ -260,7 +276,7 @@ loadBackups()
-
+
{{ t('settings.restoreBackupTitle') }}
{{ t('settings.restoreWarning') }}
diff --git a/neode-ui/src/views/settings/ChangePasswordSection.vue b/neode-ui/src/views/settings/ChangePasswordSection.vue
index eb64d6b4..b0308631 100644
--- a/neode-ui/src/views/settings/ChangePasswordSection.vue
+++ b/neode-ui/src/views/settings/ChangePasswordSection.vue
@@ -13,6 +13,7 @@ useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePas
const changingPassword = ref(false)
const changePasswordError = ref('')
const changePasswordSuccess = ref('')
+const changePasswordWarning = ref('')
const changePasswordForm = ref({
currentPassword: '',
newPassword: '',
@@ -32,6 +33,7 @@ function validatePasswordStrength(pw: string): string | null {
async function handleChangePassword() {
changePasswordError.value = ''
changePasswordSuccess.value = ''
+ changePasswordWarning.value = ''
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
if (!currentPassword || !newPassword || !confirmPassword) {
changePasswordError.value = t('settings.passwordAllFieldsRequired')
@@ -48,16 +50,21 @@ async function handleChangePassword() {
}
changingPassword.value = true
try {
- await rpcClient.changePassword({
+ const result = await rpcClient.changePassword({
currentPassword,
newPassword,
alsoChangeSsh,
})
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
+ if (alsoChangeSsh && result.ssh_error) {
+ changePasswordWarning.value = `${t('settings.passwordUpdatedSshFailed')} ${result.ssh_error}`
+ }
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
- setTimeout(() => {
- closeChangePasswordModal()
- }, 2000)
+ if (!changePasswordWarning.value) {
+ setTimeout(() => {
+ closeChangePasswordModal()
+ }, 2000)
+ }
} catch (e) {
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
} finally {
@@ -70,6 +77,7 @@ function closeChangePasswordModal() {
showChangePasswordModal.value = false
changePasswordError.value = ''
changePasswordSuccess.value = ''
+ changePasswordWarning.value = ''
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
@@ -93,7 +101,7 @@ function closeChangePasswordModal() {
@@ -139,6 +147,7 @@ function closeChangePasswordModal() {
{{ changePasswordError }}
{{ changePasswordSuccess }}
+
{{ changePasswordWarning }}
Auto = FIPS first, Tor on failure.
- Loading…
- {{ error }}
+ Loading…
+ {{ error }}
+
+
+
+
+
+ Refreshing transport preferences...
+
+
+ {{ error }}
+
(null)
const error = ref
(null)
async function load() {
+ const hadPrefs = prefs.value !== null
loading.value = true
error.value = null
try {
prefs.value = await rpcClient.call({ method: 'transport.preferences' })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load transport preferences'
+ if (!hadPrefs) prefs.value = null
} finally {
loading.value = false
}
@@ -118,4 +130,6 @@ async function setPref(service: Service, pref: Pref) {
}
onMounted(load)
+
+defineExpose({ load })
diff --git a/neode-ui/src/views/settings/TwoFactorSection.vue b/neode-ui/src/views/settings/TwoFactorSection.vue
index d08d1b0d..537a34b8 100644
--- a/neode-ui/src/views/settings/TwoFactorSection.vue
+++ b/neode-ui/src/views/settings/TwoFactorSection.vue
@@ -172,7 +172,7 @@ loadTotpStatus()
@@ -279,7 +279,7 @@ loadTotpStatus()
diff --git a/neode-ui/src/views/settings/VpnStatusSection.vue b/neode-ui/src/views/settings/VpnStatusSection.vue
index 667f7cf6..20e2db44 100644
--- a/neode-ui/src/views/settings/VpnStatusSection.vue
+++ b/neode-ui/src/views/settings/VpnStatusSection.vue
@@ -38,6 +38,8 @@ function formatBytes(bytes: number): string {
}
onMounted(fetchVpnStatus)
+
+defineExpose({ fetchVpnStatus })
@@ -65,29 +67,47 @@ onMounted(fetchVpnStatus)
- Loading VPN status...
+ Loading VPN status...
-
-
-
Provider
-
{{ vpnStatus.provider || 'wireguard' }}
+
+
+
+
+
+
+ Refreshing VPN status...
-
-
Peers
-
{{ vpnStatus.peers_connected }}
-
-
-
VPN Address
-
{{ vpnStatus.ip_address }}
-
-
-
Traffic
-
{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}
+
+
+
Provider
+
{{ vpnStatus.provider || 'wireguard' }}
+
+
+
Peers
+
{{ vpnStatus.peers_connected }}
+
+
+
VPN Address
+
{{ vpnStatus.ip_address }}
+
+
+
Traffic
+
{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}
+
-
- VPN will activate automatically when peers are discovered via Nostr relays.
+
+
+
+
+
+
+ Refreshing VPN status...
+
+
+ VPN will activate automatically when peers are discovered via Nostr relays.
+
{{ error }}
diff --git a/neode-ui/src/views/settings/__tests__/BackupSection.test.ts b/neode-ui/src/views/settings/__tests__/BackupSection.test.ts
new file mode 100644
index 00000000..7219fba8
--- /dev/null
+++ b/neode-ui/src/views/settings/__tests__/BackupSection.test.ts
@@ -0,0 +1,72 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import BackupSection from '../BackupSection.vue'
+import { rpcClient } from '@/api/rpc-client'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}))
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ call: vi.fn(),
+ },
+}))
+
+function deferred
() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+function makeBackup() {
+ return {
+ id: 'backup-one',
+ created_at: '2026-06-10T10:00:00Z',
+ size_bytes: 2048,
+ encrypted: true,
+ description: 'Before upgrade',
+ }
+}
+
+describe('BackupSection', () => {
+ it('keeps backups visible while refresh is pending or fails', async () => {
+ vi.mocked(rpcClient.call).mockResolvedValueOnce({ backups: [makeBackup()] })
+
+ const wrapper = mount(BackupSection, {
+ global: {
+ stubs: {
+ Teleport: true,
+ },
+ },
+ })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Before upgrade')
+ expect(wrapper.text()).toContain('2.0 KB')
+
+ const pending = deferred<{ backups: [] }>()
+ vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
+
+ const refresh = (wrapper.vm as unknown as { loadBackups: () => Promise }).loadBackups()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Before upgrade')
+ expect(wrapper.text()).toContain('Refreshing backups...')
+ expect(wrapper.text()).not.toContain('settings.loadingBackups')
+ expect(wrapper.text()).not.toContain('settings.noBackups')
+
+ pending.reject(new Error('offline'))
+ await refresh
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Before upgrade')
+ expect(wrapper.text()).toContain('offline')
+ expect(wrapper.text()).not.toContain('Refreshing backups...')
+ expect(wrapper.text()).not.toContain('settings.noBackups')
+ })
+})
diff --git a/neode-ui/src/views/settings/__tests__/ChangePasswordSection.test.ts b/neode-ui/src/views/settings/__tests__/ChangePasswordSection.test.ts
new file mode 100644
index 00000000..8a841cf8
--- /dev/null
+++ b/neode-ui/src/views/settings/__tests__/ChangePasswordSection.test.ts
@@ -0,0 +1,58 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ChangePasswordSection from '../ChangePasswordSection.vue'
+
+const { changePassword } = vi.hoisted(() => ({
+ changePassword: vi.fn(),
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}))
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ changePassword,
+ },
+}))
+
+vi.mock('@/composables/useModalKeyboard', () => ({
+ useModalKeyboard: vi.fn(),
+}))
+
+describe('ChangePasswordSection', () => {
+ beforeEach(() => {
+ changePassword.mockReset()
+ })
+
+ it('shows a warning when SSH update fails after web password update succeeds', async () => {
+ changePassword.mockResolvedValueOnce({
+ success: true,
+ ssh_updated: false,
+ ssh_error: 'sudo unavailable',
+ })
+ const wrapper = mount(ChangePasswordSection, {
+ global: {
+ stubs: {
+ Teleport: true,
+ },
+ },
+ })
+
+ await wrapper.find('button').trigger('click')
+ const inputs = wrapper.findAll('input')
+ await inputs[0]!.setValue('password123')
+ await inputs[1]!.setValue('MyP@ssw0rd!123')
+ await inputs[2]!.setValue('MyP@ssw0rd!123')
+ await wrapper.find('form').trigger('submit.prevent')
+
+ expect(changePassword).toHaveBeenCalledWith({
+ currentPassword: 'password123',
+ newPassword: 'MyP@ssw0rd!123',
+ alsoChangeSsh: true,
+ })
+ expect(wrapper.text()).toContain('settings.passwordUpdatedSuccess')
+ expect(wrapper.text()).toContain('settings.passwordUpdatedSshFailed sudo unavailable')
+ expect(wrapper.text()).not.toContain('settings.passwordChangeFailed')
+ })
+})
diff --git a/neode-ui/src/views/settings/__tests__/TransportPrefsCard.test.ts b/neode-ui/src/views/settings/__tests__/TransportPrefsCard.test.ts
new file mode 100644
index 00000000..c8c7923e
--- /dev/null
+++ b/neode-ui/src/views/settings/__tests__/TransportPrefsCard.test.ts
@@ -0,0 +1,65 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import TransportPrefsCard from '../TransportPrefsCard.vue'
+import { rpcClient } from '@/api/rpc-client'
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ call: vi.fn(),
+ },
+}))
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+function makePrefs() {
+ return {
+ federation: 'auto',
+ peers: 'fips',
+ peer_files: 'tor',
+ messaging: 'auto',
+ mesh_file_sharing: 'fips',
+ }
+}
+
+describe('TransportPrefsCard', () => {
+ it('keeps transport preferences visible while refresh is pending or fails', async () => {
+ vi.mocked(rpcClient.call).mockResolvedValueOnce(makePrefs())
+
+ const wrapper = mount(TransportPrefsCard)
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Federation')
+ expect(wrapper.text()).toContain('Peer Files')
+ expect(wrapper.text()).toContain('Auto')
+ expect(wrapper.text()).toContain('FIPS')
+ expect(wrapper.text()).toContain('Tor')
+
+ const pending = deferred>()
+ vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
+
+ const refresh = (wrapper.vm as unknown as { load: () => Promise }).load()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Federation')
+ expect(wrapper.text()).toContain('Peer Files')
+ expect(wrapper.text()).toContain('Refreshing transport preferences...')
+ expect(wrapper.text()).not.toContain('Loading…')
+
+ pending.reject(new Error('offline'))
+ await refresh
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Federation')
+ expect(wrapper.text()).toContain('Peer Files')
+ expect(wrapper.text()).toContain('offline')
+ expect(wrapper.text()).not.toContain('Refreshing transport preferences...')
+ })
+})
diff --git a/neode-ui/src/views/settings/__tests__/VpnStatusSection.test.ts b/neode-ui/src/views/settings/__tests__/VpnStatusSection.test.ts
new file mode 100644
index 00000000..5c552e7e
--- /dev/null
+++ b/neode-ui/src/views/settings/__tests__/VpnStatusSection.test.ts
@@ -0,0 +1,66 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import VpnStatusSection from '../VpnStatusSection.vue'
+import { rpcClient } from '@/api/rpc-client'
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ call: vi.fn(),
+ },
+}))
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+function makeVpnStatus() {
+ return {
+ connected: true,
+ provider: 'wireguard',
+ interface: 'wg0',
+ ip_address: '10.0.0.2/32',
+ hostname: 'node',
+ peers_connected: 2,
+ bytes_in: 1024,
+ bytes_out: 2048,
+ }
+}
+
+describe('VpnStatusSection', () => {
+ it('keeps VPN status visible while refresh is pending or fails', async () => {
+ vi.mocked(rpcClient.call).mockResolvedValueOnce(makeVpnStatus())
+
+ const wrapper = mount(VpnStatusSection)
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Connected')
+ expect(wrapper.text()).toContain('wireguard')
+ expect(wrapper.text()).toContain('10.0.0.2/32')
+
+ const pending = deferred>()
+ vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
+
+ const refresh = (wrapper.vm as unknown as { fetchVpnStatus: () => Promise }).fetchVpnStatus()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.text()).toContain('Connected')
+ expect(wrapper.text()).toContain('wireguard')
+ expect(wrapper.text()).toContain('Refreshing VPN status...')
+ expect(wrapper.text()).not.toContain('Loading VPN status...')
+
+ pending.reject(new Error('offline'))
+ await refresh
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Connected')
+ expect(wrapper.text()).toContain('wireguard')
+ expect(wrapper.text()).toContain('offline')
+ expect(wrapper.text()).not.toContain('Refreshing VPN status...')
+ })
+})
diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue
index 17e2e384..dfbb704a 100644
--- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue
+++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue
@@ -11,7 +11,7 @@
{{ t('web5.connectedNodes') }}
-
+
- {{ loadingPeers ? '...' : t('common.refresh') }}
+ {{ loadingPeers ? t('common.loading') : t('common.refresh') }}
@@ -36,32 +37,26 @@
{{ t('web5.connectedNodes') }}
-
-
- {{ t('web5.findNodes') }}
-
-
- {{ loadingPeers ? '...' : t('common.refresh') }}
-
-
-
+
- {{ t('web5.peers') }}
+ {{ t('web5.trusted') }}
({{ peers.length }})
+
+ {{ t('web5.observers') }}
+ ({{ observers.length }})
+
-
-
-
+
+
+
+ {{ t('common.loading') }}
+
+
{{ t('web5.noPeers') }}
+
+ {{ t('common.loading') }}
+
+
+
+
+ {{ t('common.loading') }}
+
+
+ {{ t('web5.noObservers') }}
+
+
+ {{ t('common.loading') }}
+
+
+
+
+
+
{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}
+
{{ p.onion }}
+
+
+
+ {{ t('web5.observer') }}
+
+
+
+
-
+
{{ t('common.loading') }}
{{ t('web5.noMessages') }}
+
+ {{ t('common.loading') }}
+
-
+
{{ t('common.loading') }}
{{ t('web5.noRequests') }}
+
+ {{ t('common.loading') }}
+
-
+
+
+
+ {{ t('web5.findNodes') }}
+
+
+ {{ loadingPeers ? t('common.loading') : t('common.refresh') }}
+
+
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
+
+ {{ loadingPeers ? t('common.loading') : t('common.refresh') }}
+
(null)
-const nodesContainerTab = ref<'peers' | 'messages' | 'requests'>('peers')
+const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
const peers = ref([])
+const observers = ref([])
const loadingPeers = ref(false)
const peerReachableLocal = ref>({})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
@@ -300,11 +360,21 @@ const emit = defineEmits<{
}>()
function peerNameFromPubkey(pubkey: string): string {
- const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
+ const peer = [...peers.value, ...observers.value].find(p => p.pubkey === pubkey || p.onion === pubkey)
if (peer?.name) return peer.name
return (pubkey || '').slice(0, 16) + '...'
}
+type FederationNode = Awaited>['nodes'][number]
+
+function federationNodeToPeer(node: FederationNode): Peer {
+ return {
+ onion: node.onion,
+ pubkey: node.pubkey,
+ name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
+ }
+}
+
function switchToMessagesTab() {
nodesContainerTab.value = 'messages'
markAsRead()
@@ -322,13 +392,25 @@ async function loadPeers() {
try {
const res = await rpcClient.listPeers()
const peerList = res.peers || []
+ const observerList: Peer[] = []
try {
const fedRes = await rpcClient.federationListNodes()
const fedNodes = fedRes.nodes || []
for (const n of fedNodes) {
- if (n.onion && !peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
- peerList.push({ onion: n.onion, pubkey: n.pubkey, name: n.name || `Federation: ${n.did?.slice(0, 16) || 'node'}` })
+ 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 {
@@ -336,7 +418,8 @@ async function loadPeers() {
}
peers.value = peerList
- for (const p of peers.value) {
+ observers.value = observerList
+ for (const p of [...peers.value, ...observers.value]) {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
@@ -394,13 +477,14 @@ async function discoverAndAddPeers() {
}
async function loadConnectionRequests() {
+ const hadRequests = connectionRequests.value.length > 0
loadingRequests.value = true
try {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length
} catch {
- connectionRequests.value = []
+ if (!hadRequests) connectionRequests.value = []
} finally {
loadingRequests.value = false
}
@@ -443,5 +527,5 @@ function scrollToMessages() {
})
}
-defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, scrollToMessages })
+defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers, scrollToMessages })
diff --git a/neode-ui/src/views/web5/Web5CredentialsSummary.vue b/neode-ui/src/views/web5/Web5CredentialsSummary.vue
index e1d3de20..057d7296 100644
--- a/neode-ui/src/views/web5/Web5CredentialsSummary.vue
+++ b/neode-ui/src/views/web5/Web5CredentialsSummary.vue
@@ -51,7 +51,20 @@
-
+
+ Loading credentials...
+
+
+
+
+
+
+
+ Refreshing credentials...
+
+
+ {{ credentialsError }}
+
{{ vc.type }}
@@ -86,13 +99,21 @@ defineProps<{
}>()
const vcCredentials = ref
([])
+const credentialsLoading = ref(false)
+const credentialsError = ref('')
async function loadCredentials() {
+ const hadCredentials = vcCredentials.value.length > 0
+ credentialsLoading.value = true
+ credentialsError.value = ''
try {
const res = await rpcClient.call<{ credentials: VCData[] }>({ method: 'identity.list-credentials' })
vcCredentials.value = res.credentials || []
- } catch {
- vcCredentials.value = []
+ } catch (e: unknown) {
+ credentialsError.value = e instanceof Error ? e.message : 'Failed to load credentials'
+ if (!hadCredentials) vcCredentials.value = []
+ } finally {
+ credentialsLoading.value = false
}
}
diff --git a/neode-ui/src/views/web5/Web5DWN.vue b/neode-ui/src/views/web5/Web5DWN.vue
index 479119d8..9e06f873 100644
--- a/neode-ui/src/views/web5/Web5DWN.vue
+++ b/neode-ui/src/views/web5/Web5DWN.vue
@@ -119,9 +119,16 @@
-
Loading messages...
+
Loading messages...
No messages stored
+
+
+
+
+
+ Refreshing messages...
+
{{ (msg.record_id || '').slice(0, 8) }}...
@@ -261,16 +268,17 @@ async function toggleDwnMessages() {
}
async function loadDwnMessages() {
+ const hadMessages = dwnMessages.value.length > 0
loadingDwnMessages.value = true
try {
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
dwnMessages.value = res.messages || []
} catch {
- dwnMessages.value = []
+ if (!hadMessages) dwnMessages.value = []
} finally {
loadingDwnMessages.value = false
}
}
-defineExpose({ loadDwnStatus, loadDwnProtocols })
+defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
diff --git a/neode-ui/src/views/web5/Web5Domains.vue b/neode-ui/src/views/web5/Web5Domains.vue
index a0942589..f6833b3a 100644
--- a/neode-ui/src/views/web5/Web5Domains.vue
+++ b/neode-ui/src/views/web5/Web5Domains.vue
@@ -14,6 +14,17 @@
+
+
+
+
+
+ Refreshing domains...
+
+
+ {{ domainsLoadError }}
+
+
@@ -152,6 +163,8 @@ const newDomainDomain = ref('')
const newDomainIdentityId = ref('')
const domainError = ref('')
const domainRegistering = ref(false)
+const domainsLoading = ref(false)
+const domainsLoadError = ref('')
const verifyNip05Input = ref('')
const nip05Verifying = ref(false)
const nip05Result = ref(null)
@@ -160,11 +173,17 @@ const activeNamesCount = computed(() => registeredNames.value.filter(n => n.stat
const expiringNamesCount = computed(() => registeredNames.value.filter(n => n.status === 'expired' || n.expires_at).length)
async function loadDomainNames() {
+ const hadNames = registeredNames.value.length > 0
+ domainsLoading.value = true
+ domainsLoadError.value = ''
try {
const res = await rpcClient.call<{ names: RegisteredNameData[] }>({ method: 'identity.list-names' })
registeredNames.value = res.names || []
- } catch {
- registeredNames.value = []
+ } catch (e: unknown) {
+ domainsLoadError.value = e instanceof Error ? e.message : 'Failed to load domains'
+ if (!hadNames) registeredNames.value = []
+ } finally {
+ domainsLoading.value = false
}
}
diff --git a/neode-ui/src/views/web5/Web5Federation.vue b/neode-ui/src/views/web5/Web5Federation.vue
index 59c2530a..d6720cb6 100644
--- a/neode-ui/src/views/web5/Web5Federation.vue
+++ b/neode-ui/src/views/web5/Web5Federation.vue
@@ -13,15 +13,31 @@
Federated nodes & peers
-
- Details
-
-
-
-
+
+
+ Find Nodes
+
+
+ Fleet
+
+
+
+
+
+
+
+ Refreshing federation...
+
+
+ {{ federationError }}
+
+
+ Loading federation...
+
+
@@ -55,6 +71,15 @@
{{ selfDid || 'Not set' }}
+
+
+
+ Find Nodes
+
+
+ Fleet
+
+
@@ -67,8 +92,13 @@ const nodeCount = ref(0)
const onlineCount = ref(0)
const pendingCount = ref(0)
const selfDid = ref('')
+const loadingFederation = ref(false)
+const hasLoadedFederation = ref(false)
+const federationError = ref('')
-onMounted(async () => {
+async function loadFederationSummary() {
+ loadingFederation.value = true
+ federationError.value = ''
try {
const res = await rpcClient.call<{
nodes: Array<{ status: string }>
@@ -78,11 +108,22 @@ onMounted(async () => {
nodeCount.value = nodes.length
onlineCount.value = nodes.filter(n => n.status === 'online' || n.status === 'connected').length
pendingCount.value = (res.pending_requests || []).length
- } catch { /* unavailable */ }
+ hasLoadedFederation.value = true
+ } catch (e: unknown) {
+ federationError.value = e instanceof Error ? e.message : 'Federation unavailable'
+ }
try {
const res = await rpcClient.getNodeDid()
selfDid.value = res.did || ''
- } catch { /* unavailable */ }
-})
+ } catch (e: unknown) {
+ if (!federationError.value) federationError.value = e instanceof Error ? e.message : 'Node identity unavailable'
+ } finally {
+ loadingFederation.value = false
+ }
+}
+
+onMounted(loadFederationSummary)
+
+defineExpose({ loadFederationSummary })
diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue
index 30777af2..c6a0519a 100644
--- a/neode-ui/src/views/web5/Web5Identities.vue
+++ b/neode-ui/src/views/web5/Web5Identities.vue
@@ -14,10 +14,7 @@
{{ t('web5.identitiesDesc') }}
-
-
-
-
+
Create
@@ -32,16 +29,10 @@
{{ t('web5.identities') }}
{{ t('web5.identitiesDesc') }}
-
-
-
-
- Create
-
-
+
@@ -60,6 +51,13 @@
+
+
+
+
+
+ Refreshing identities...
+
+
+
+ Create
+
@@ -501,12 +503,13 @@ const profileError = ref('')
const profileSuccess = ref('')
async function loadIdentities() {
+ const hadIdentities = managedIdentities.value.length > 0
identitiesLoading.value = true
try {
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
managedIdentities.value = res.identities || []
} catch {
- managedIdentities.value = []
+ if (!hadIdentities) managedIdentities.value = []
} finally {
identitiesLoading.value = false
}
diff --git a/neode-ui/src/views/web5/Web5Monitoring.vue b/neode-ui/src/views/web5/Web5Monitoring.vue
index b0f60a45..bd0c7447 100644
--- a/neode-ui/src/views/web5/Web5Monitoring.vue
+++ b/neode-ui/src/views/web5/Web5Monitoring.vue
@@ -13,11 +13,8 @@
System resources & health
-
+
Details
-
-
-
@@ -67,6 +64,10 @@
{{ uptimeDisplay }}
+
+
+ Details
+
diff --git a/neode-ui/src/views/web5/Web5NostrRelays.vue b/neode-ui/src/views/web5/Web5NostrRelays.vue
index 3e80c6e7..2d422f98 100644
--- a/neode-ui/src/views/web5/Web5NostrRelays.vue
+++ b/neode-ui/src/views/web5/Web5NostrRelays.vue
@@ -14,6 +14,17 @@
+
+
+
+
+
+ Refreshing relays...
+
+
+ {{ relaysLoadError }}
+
+