diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 75a84b81..05013f55 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -41,6 +41,7 @@ :refresh-key="refreshKey" :blocked-reason="blockedReason" :blocked-title="blockedTitle" + :warming-up="warmingUp" :electrs-sync="electrsSync" @iframe-load="onLoad" @iframe-error="onError" @@ -112,6 +113,7 @@ import { initialDisplayMode, resolveAppUrl, resolveAppTitle, } from './appSession/appSessionConfig' import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig' +import { PackageState } from '@/types/api' import { useAppIdentity } from './appSession/useAppIdentity' import { useNostrBridge } from './appSession/useNostrBridge' import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal' @@ -168,6 +170,25 @@ const appIcon = computed(() => : `/assets/img/app-icons/${appId.value}.png` ) const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value)) + +// A container that is up but not yet answering its probe is STARTING, not +// broken — bitcoind serves RPC error -28 for its whole warm-up and lnd is +// unreachable until the wallet unlocks, so both spent that window reading as +// a hard "App not reachable" failure. The retry machinery below already +// tolerates it (6 × 10s); this only makes the headline tell the truth while +// those retries are still in flight. Once they are exhausted, the failure is +// real again and the copy reverts. +const MAX_AUTO_RETRIES = 6 +const warmingUp = computed(() => + iframeBlocked.value && + !mustOpenNewTab.value && + !blockedReason.value && + autoRetryCount.value < MAX_AUTO_RETRIES && + (packageEntry.value?.state === PackageState.Running || + packageEntry.value?.state === PackageState.Starting || + packageEntry.value?.state === PackageState.Restarting || + packageEntry.value?.health === 'starting') +) const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready') // Reactive so the overlay/teleport/footer/animation decisions track the live // viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read. @@ -350,7 +371,7 @@ function onError() { isRefreshing.value = false iframeBlocked.value = true // Auto-retry up to 6 times (60s total) for apps that are still starting - if (!mustOpenNewTab.value && autoRetryCount.value < 6) { + if (!mustOpenNewTab.value && autoRetryCount.value < MAX_AUTO_RETRIES) { autoRetryId = setTimeout(() => { autoRetryCount.value++ refresh() diff --git a/neode-ui/src/views/appSession/AppSessionFrame.vue b/neode-ui/src/views/appSession/AppSessionFrame.vue index 883003ac..4cb98dcf 100644 --- a/neode-ui/src/views/appSession/AppSessionFrame.vue +++ b/neode-ui/src/views/appSession/AppSessionFrame.vue @@ -68,14 +68,18 @@
-
- + +
+ +
-

{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}

+

{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}

+

@@ -131,6 +135,9 @@ const props = defineProps<{ refreshKey: number blockedReason?: string blockedTitle?: string + // True while the container is up but its probe hasn't answered yet and the + // auto-retries are still in flight — a warm-up, not a failure. + warmingUp?: boolean // Non-null only for ElectrumX while its index is still building — shows the // sync screen and gates the iframe until status flips to "synced". electrsSync?: ElectrsSyncStatus | null diff --git a/neode-ui/src/views/appSession/__tests__/AppSessionFrame.test.ts b/neode-ui/src/views/appSession/__tests__/AppSessionFrame.test.ts new file mode 100644 index 00000000..b616198a --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/AppSessionFrame.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import AppSessionFrame from '../AppSessionFrame.vue' + +// Regression cover for the operator-reported defect: a container that is up +// but has not finished warming up (bitcoind serving RPC -28, lnd before the +// wallet unlocks) rendered the hard "App not reachable" failure copy for the +// whole warm-up window. The retry machinery already tolerated it — only the +// headline lied. + +function mountFrame(props: Record = {}) { + return mount(AppSessionFrame, { + props: { + appUrl: 'http://localhost:8332/', + appId: 'bitcoin-knots', + appTitle: 'Bitcoin', + appIcon: '/icons/bitcoin.png', + loading: false, + iframeBlocked: true, + mustOpenNewTab: false, + autoRetryCount: 1, + refreshKey: 0, + ...props, + }, + global: { stubs: { AppLoadingScreen: true, Transition: false } }, + }) +} + +describe('AppSessionFrame warm-up state', () => { + it('reads as starting, not unreachable, while the container is warming up', () => { + const text = mountFrame({ warmingUp: true }).text() + expect(text).toContain('Bitcoin is starting…') + expect(text).not.toContain('App not reachable') + }) + + it('says the container is running so the copy does not imply it is stopped', () => { + const text = mountFrame({ warmingUp: true }).text() + expect(text).toContain("container is running but hasn't finished warming up") + expect(text).not.toContain('the container is stopped') + }) + + it('still surfaces the automatic re-check while warming up', () => { + expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain( + 'Checking again automatically (3)', + ) + }) + + it('reverts to the real failure once warm-up is over (retries exhausted)', () => { + const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text() + expect(text).toContain('App not reachable') + expect(text).not.toContain('is starting…') + }) + + it('leaves the explicit blocked-reason path untouched', () => { + const text = mountFrame({ + warmingUp: false, + blockedReason: 'Waiting for Bitcoin to finish syncing.', + blockedTitle: 'Waiting for Bitcoin sync', + }).text() + expect(text).toContain('Waiting for Bitcoin sync') + expect(text).not.toContain('App not reachable') + }) + + it('leaves the new-tab path untouched', () => { + const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text() + expect(text).toContain('This app opens in a new tab') + }) +})