From caaa2e729ebdb390e54a7f6b2a3b54c38d8339d5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 12 Sep 2026 09:35:25 -0400 Subject: [PATCH] fix: gate app launches on health readiness --- neode-ui/src/stores/appLauncher.ts | 11 +++++++++- .../views/apps/__tests__/appsConfig.test.ts | 15 ++++++++++++- neode-ui/src/views/apps/appsConfig.ts | 22 +++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 72755caf..50e5818c 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -7,7 +7,8 @@ import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig' import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig' import { useAppStore } from '@/stores/app' -import { resolveAppIcon } from '@/views/apps/appsConfig' +import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig' +import { useToast } from '@/composables/useToast' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' import type { AppCredential, AppCredentialsResponse } from '@/types/api' import { resolveAppCredentials } from '@/views/apps/appCredentials' @@ -290,6 +291,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { * Previously each Apps view owned a private modal, so Home skipped the * Portainer first-run token entirely. */ function openSession(appId: string, opts: LaunchOptions = {}) { + // Home/goal/deep-link launchers do not pass through AppCard.canLaunch. + // Apply the same readiness gate here so a container that has just entered + // `running` cannot race nginx and show a transient 502 to the user. + const pkg = useAppStore().data?.['package-data']?.[appId] + if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) { + useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`) + return + } if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) { void prepareCredentialLaunch(appId, opts.path) return diff --git a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts index 691bea67..9011cc6a 100644 --- a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts +++ b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { ref } from 'vue' import { PackageState, type PackageDataEntry } from '@/types/api' -import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig' +import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig' function makePkg(id: string, title: string, category: string): PackageDataEntry { return { @@ -141,6 +141,19 @@ describe('appsConfig service filtering', () => { expect(canLaunch(confirmedUi)).toBe(true) }) + it('does not launch a health-checked app during the running-before-ready race', () => { + const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development') + ;(pkg.manifest as unknown as Record).interfaces = { main: { ui: 'true' } } + ;(pkg.manifest as unknown as Record).health_check = { path: '/healthz' } + pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed'] + pkg.health = null + expect(isAppReadyForLaunch(pkg)).toBe(false) + expect(canLaunch(pkg)).toBe(false) + expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up') + pkg.health = 'healthy' + expect(canLaunch(pkg)).toBe(true) + }) + it('never offers Launch for curated service containers even with a UI flag', () => { const service = makePkg('indeedhub-api', 'IndeeHub API', 'media') ;(service.manifest as unknown as Record).interfaces = { main: { ui: 'true' } } diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index 21ab7b07..94da9181 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -259,11 +259,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean { // the tile stays launchable while the backend is still 'starting' (ElectrumX // indexes for 10m+ on first run). A genuinely 'unhealthy' backend still // blocks. Apps that rely on a runtime interface-address keep the strict gate. - const blockedByHealth = - pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl) + const blockedByHealth = !isAppReadyForLaunch(pkg) || + (pkg.health === 'starting' && !hasKnownLaunchUrl) return !!hasUI && pkg.state === 'running' && !blockedByHealth } +/** + * A published port is not the same thing as a usable app. During the short + * interval between the container entering `running` and its HTTP health check + * passing, nginx quite correctly returns 502 because the upstream has not + * bound its socket yet. Keep every app with a declared health check out of + * the launch path until the platform has observed readiness. Apps without a + * health check retain the legacy state/port behaviour. + */ +export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean { + const manifest = pkg.manifest as unknown as Record + const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check']) + if (!hasHealthCheck) return pkg.health !== 'unhealthy' + return pkg.health === 'healthy' +} + export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string { const appId = pkg?.manifest?.id || id if ( @@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): ) { return 'Guardian opens a wait page until Bitcoin finishes initial sync.' } + if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) { + return 'Starting up — Launch will appear when the app is ready.' + } return '' }