fix(ui): a warming-up app reads as "starting", not "App not reachable"
Demo images / Build & push demo images (push) Failing after 3m45s
Demo images / Build & push demo images (push) Failing after 3m45s
A container that is up but hasn't answered its probe yet rendered the hard failure overlay — padlock icon, "App not reachable", "the container is stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable until the wallet unlocks) sit in that window on every boot, so the node looked broken while it was working normally. The retry machinery was already correct: 6 × 10s of automatic re-checks, and the app appears on its own when it answers. Only the headline was wrong. While those retries are in flight AND the package reports running/starting/restarting (or health "starting"), the overlay now shows the app's own pulsing icon, "<App> is starting…", and says the container is running. Once retries are exhausted the failure is real again and the original copy returns. Follows the ElectrumX sync-screen precedent already in this file, which suppresses the same overlay for the same reason. The explicit blocked-reason and must-open-new-tab paths are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab69400956
commit
c65ee03a5c
@@ -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()
|
||||
|
||||
@@ -68,14 +68,18 @@
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
||||
the padlock reads as "blocked/denied" and this state is neither. -->
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden" :class="{ 'animate-pulse': warmingUp }">
|
||||
<img v-if="warmingUp" :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
<svg v-else class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
</p>
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user