diff --git a/neode-ui/src/stores/__tests__/homeStatus.test.ts b/neode-ui/src/stores/__tests__/homeStatus.test.ts new file mode 100644 index 00000000..2a1cde81 --- /dev/null +++ b/neode-ui/src/stores/__tests__/homeStatus.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Mock the rpc-client module +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + call: vi.fn(), + vpnStatus: vi.fn(), + }, +})) + +import { useHomeStatusStore } from '../homeStatus' +import { rpcClient } from '@/api/rpc-client' +import { PackageState, type PackageDataEntry } from '@/types/api' + +const mockedRpc = vi.mocked(rpcClient) + +function pkg(state: string): Record { + return { 'bitcoin-knots': { state } as unknown as PackageDataEntry } +} + +describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-live)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('records a successful poll as available + not stale', async () => { + const store = useHomeStatusStore() + mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 }) + await store.refreshBitcoin({}) + expect(store.stats.bitcoinAvailable).toBe(true) + expect(store.stats.bitcoinSyncPercent).toBe(100) + expect(store.bitcoinStale).toBe(false) + expect(store.bitcoinLoadState).toBe('ready') + }) + + it('keeps the tile visible (available) but marks stale when getinfo fails while the container is Running', async () => { + const store = useHomeStatusStore() + // First a good poll so we have real sync numbers. + mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 0.5 }) + await store.refreshBitcoin(pkg(PackageState.Running)) + expect(store.stats.bitcoinAvailable).toBe(true) + + // Now a transient RPC failure (e.g. RPC busy during heavy IBD) — container still Running. + mockedRpc.call.mockRejectedValueOnce(new Error('timeout')) + await store.refreshBitcoin(pkg(PackageState.Running)) + expect(store.stats.bitcoinAvailable).toBe(true) // does NOT vanish + expect(store.bitcoinStale).toBe(true) // shown as "Updating…", not live + expect(store.stats.bitcoinSyncPercent).toBe(50) // last-known retained + }) + + it('flips to NOT available (and not stale) when getinfo fails and the container is Stopped — no stale-as-live', async () => { + const store = useHomeStatusStore() + mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 }) + await store.refreshBitcoin(pkg(PackageState.Running)) + expect(store.stats.bitcoinAvailable).toBe(true) + + mockedRpc.call.mockRejectedValueOnce(new Error('refused')) + await store.refreshBitcoin(pkg(PackageState.Stopped)) + expect(store.stats.bitcoinAvailable).toBe(false) // genuinely down → reflect it + expect(store.bitcoinStale).toBe(false) // not "Updating…": it's authoritatively stopped + }) + + it('retains the last-known available value (marked stale) when package data is momentarily absent', async () => { + const store = useHomeStatusStore() + mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 }) + await store.refreshBitcoin(pkg(PackageState.Running)) + expect(store.stats.bitcoinAvailable).toBe(true) + + // getinfo fails AND the packages map has no authoritative bitcoin entry (route change / scan). + mockedRpc.call.mockRejectedValueOnce(new Error('timeout')) + await store.refreshBitcoin({}) + expect(store.stats.bitcoinAvailable).toBe(true) // retained, does NOT flash "Not running" + expect(store.bitcoinStale).toBe(true) + expect(store.bitcoinLoadState).toBe('ready') + }) + + it('stays unknown (null) without fabricating availability when the first ever poll fails with no package data', async () => { + const store = useHomeStatusStore() + mockedRpc.call.mockRejectedValueOnce(new Error('timeout')) + await store.refreshBitcoin({}) + expect(store.stats.bitcoinAvailable).toBeNull() // nothing known yet — don't invent a tile + expect(store.bitcoinLoadState).toBe('error') + }) + + it('marks bitcoin available + stale when the first poll times out but the container is Running (syncing node)', async () => { + const store = useHomeStatusStore() + // No prior success; getinfo times out during heavy initial sync, but container is up. + mockedRpc.call.mockRejectedValueOnce(new Error('timeout')) + await store.refreshBitcoin(pkg(PackageState.Running)) + expect(store.stats.bitcoinAvailable).toBe(true) // tile appears instead of staying hidden + expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet + }) +}) diff --git a/neode-ui/src/stores/homeStatus.ts b/neode-ui/src/stores/homeStatus.ts index 41ae1948..435d78e2 100644 --- a/neode-ui/src/stores/homeStatus.ts +++ b/neode-ui/src/stores/homeStatus.ts @@ -43,6 +43,10 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { const stats = reactive(emptyStats()) const systemLoadState = ref('idle') const bitcoinLoadState = ref('idle') + // True when we're showing a retained (last-known) bitcoin value because the + // latest poll failed transiently — the UI renders an "Updating…" badge so the + // figure is never presented as live, and the tile never vanishes mid-sync. + const bitcoinStale = ref(false) const vpnLoadState = ref('idle') const fipsLoadState = ref('idle') const lastSystemRefreshAt = ref(null) @@ -109,26 +113,34 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100 stats.bitcoinBlockHeight = btc.block_height ?? 0 stats.bitcoinAvailable = true + bitcoinStale.value = false bitcoinLoadState.value = 'ready' lastBitcoinRefreshAt.value = Date.now() } catch { const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin if (btcPkg?.state === PackageState.Running) { + // Container is up but the RPC call failed (busy during heavy IBD, etc.). + // Keep the tile visible with the last-known figures, marked as updating. stats.bitcoinAvailable = true + bitcoinStale.value = true bitcoinLoadState.value = 'ready' lastBitcoinRefreshAt.value = Date.now() return } if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) { + // Authoritatively down — reflect it (do NOT keep showing stale data as live). stats.bitcoinAvailable = false + bitcoinStale.value = false bitcoinLoadState.value = 'ready' lastBitcoinRefreshAt.value = Date.now() return } // No authoritative package data yet. Keep the previous known value - // rather than flashing "Not running" during route changes/scans. + // rather than flashing "Not running" during route changes/scans; if we + // had a value, surface it as "updating" instead of presenting it as live. + if (stats.bitcoinAvailable !== null) bitcoinStale.value = true bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready' } } @@ -186,6 +198,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { stats, systemLoadState, bitcoinLoadState, + bitcoinStale, vpnLoadState, fipsLoadState, systemStatsLoaded, diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 654f6f6e..0224a13d 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -506,6 +506,7 @@ const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded) const systemStats = computed(() => ({ ...homeStatus.stats, bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true, + bitcoinStale: homeStatus.bitcoinStale, })) const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` }) diff --git a/neode-ui/src/views/home/HomeSystemCard.vue b/neode-ui/src/views/home/HomeSystemCard.vue index 0ea5f36b..bfb41a47 100644 --- a/neode-ui/src/views/home/HomeSystemCard.vue +++ b/neode-ui/src/views/home/HomeSystemCard.vue @@ -60,8 +60,8 @@

Bitcoin

-

- {{ stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%' }} +

+ {{ stats.bitcoinStale ? 'Updating…' : (stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%') }}

@@ -96,6 +96,7 @@ defineProps<{ bitcoinSyncPercent: number bitcoinBlockHeight: number bitcoinAvailable: boolean + bitcoinStale?: boolean } uptimeDisplay: string }>()