diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 23e89396..6c11eb1f 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -83,6 +83,7 @@ import { useAppStore } from '../stores/app' import { useAppLauncherStore } from '../stores/appLauncher' import { dummyApps } from '../utils/dummyApps' import { rpcClient } from '@/api/rpc-client' +import { useCachedResource } from '@/composables/useCachedResource' import type { AppCredentialsResponse } from '@/types/api' import BackButton from '@/components/BackButton.vue' import AppHeroSection from './appDetails/AppHeroSection.vue' @@ -162,45 +163,69 @@ const gatewayState = computed(() => { }) const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value)) -const bitcoinSyncPercent = ref(0) -const bitcoinBlockHeight = ref(0) + +// Keyed per app id (D-04): AppDetails is never instance-cached (no +// KeepAlive), but the route's `:key="route.path"` (DashboardRouterView.vue) +// means an id change always fully remounts this component, so the key can be +// computed once at setup time rather than re-derived via a watch. +const bitcoinSyncResource = useCachedResource<{ block_height: number; sync_progress: number }>({ + key: `app-details:bitcoin-sync:${appId.value}`, + fetcher: (signal) => rpcClient.call<{ block_height: number; sync_progress: number }>({ + method: 'bitcoin.getinfo', + signal, + dedup: true, + timeout: 5000, + }), + ttlMs: 30_000, // install/health-state-shaped data, per plan default + immediate: false, // kicked from onMounted, gated on needsBitcoinSync +}) +const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100) +const bitcoinBlockHeight = computed(() => bitcoinSyncResource.data.value?.block_height ?? 0) const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9) -const credentials = ref(null) -const credentialsLoading = ref(false) -const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null) -async function loadBitcoinSync() { - if (!needsBitcoinSync.value) return - try { - const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({ - method: 'bitcoin.getinfo', - timeout: 5000, - }) - bitcoinSyncPercent.value = (btc.sync_progress ?? 0) * 100 - bitcoinBlockHeight.value = btc.block_height ?? 0 - } catch { - bitcoinSyncPercent.value = 0 - bitcoinBlockHeight.value = 0 - } -} - -async function loadCredentials() { - if (!appId.value) return - credentialsLoading.value = true - try { +// Credential material — memory-only (persist: false), never written to +// sessionStorage (D-08 / T-02-01). +const credentialsResource = useCachedResource({ + key: `app-details:credentials:${appId.value}`, + fetcher: async (signal) => { const result = await rpcClient.call({ method: 'package.credentials', params: { app_id: packageKey.value }, + signal, + dedup: true, timeout: 5000, }) - credentials.value = resolveAppCredentials(packageKey.value, result) - } catch { - credentials.value = resolveAppCredentials(packageKey.value, null) - } finally { - credentialsLoading.value = false + return resolveAppCredentials(packageKey.value, result) + }, + ttlMs: 30_000, + persist: false, + immediate: false, +}) +const credentials = computed(() => credentialsResource.data.value ?? resolveAppCredentials(packageKey.value, null)) +const credentialsLoading = computed(() => credentialsResource.loadState.value === 'loading') + +// refresh() is unconditional (force-fetch); only call it when the cached +// entry is missing or past its TTL, so a repeat open inside the TTL paints +// from cache with no new RPC. +function loadBitcoinSync() { + if (!needsBitcoinSync.value) return + if (bitcoinSyncResource.data.value === null || bitcoinSyncResource.isStale.value) { + void bitcoinSyncResource.refresh() } } +function loadCredentials() { + if (!appId.value) return + if (credentialsResource.data.value === null || credentialsResource.isStale.value) { + void credentialsResource.refresh() + } +} + +const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null) + +// Both loaders are independent (bitcoin sync state vs. this app's +// credentials) and were already fire-and-forget here before this plan — do +// not "fix" this into an awaited chain, it is already effectively parallel. onMounted(() => { loadBitcoinSync() loadCredentials() @@ -301,6 +326,9 @@ async function stopApp() { pendingAction.value = 'stop' try { await store.stopPackage(appId.value) + // Stopping the app can take its admin credentials offline — invalidate + // rather than show a stale "healthy" credentials card (T-02-12). + credentialsResource.invalidate() } catch (err) { showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`) } finally { @@ -312,6 +340,9 @@ async function restartApp() { pendingAction.value = 'restart' try { await store.restartPackage(appId.value) + // A restart can rotate credentials/admin URLs — invalidate so the next + // read is fresh rather than the pre-restart cache (T-02-12). + credentialsResource.invalidate() } catch (err) { showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`) } finally { @@ -340,6 +371,10 @@ async function confirmUninstall(deleteAppData: boolean) { pendingAction.value = 'uninstall' try { await store.uninstallPackage(appId.value, { preserveData: !deleteAppData }) + // Invalidate before navigating away — the app no longer exists, so its + // cached credentials must not be replayed if this screen is reopened + // before the TTL lapses (T-02-12). + credentialsResource.invalidate() router.push('/dashboard/apps').catch(() => {}) } catch (err) { showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`) diff --git a/neode-ui/src/views/MarketplaceAppDetails.vue b/neode-ui/src/views/MarketplaceAppDetails.vue index 26dcd97d..5e3da09a 100644 --- a/neode-ui/src/views/MarketplaceAppDetails.vue +++ b/neode-ui/src/views/MarketplaceAppDetails.vue @@ -380,6 +380,8 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAppStore } from '../stores/app' import { rpcClient } from '../api/rpc-client' +import { useCachedResource } from '@/composables/useCachedResource' +import type { PackageVersionsResponse } from '../api/rpc-client' import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp' import { useAppLauncherStore } from '../stores/appLauncher' import BackButton from '@/components/BackButton.vue' @@ -411,6 +413,26 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do const appId = computed(() => route.params.id as string) +// Keyed per marketplace app id (D-04). Catalog version metadata is +// near-static, so this can take a longer TTL than the 30s default; it holds +// nothing credential/DID/wallet/tx-history-shaped so persist stays default +// (true). Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's +// captured RPC calls that isn't confounded by the Home-tab-transit artifact — +// getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune +// check below is a plain fetch(), not an rpcClient call. +const versionsResource = useCachedResource({ + key: `app-details:versions:${appId.value}`, + fetcher: (signal) => rpcClient.call({ + method: 'package.versions', + params: { id: appId.value }, + signal, + dedup: true, + timeout: 15000, + }), + ttlMs: 120_000, + immediate: false, +}) + // Web-only apps (no container, just a URL) — always treated as "installed" const isWebOnly = computed(() => { return !!(app.value?.webUrl && !app.value?.dockerImage) @@ -550,17 +572,20 @@ onMounted(() => { // Fetch the catalog's selectable versions so the install panel can offer a // choice (latest pre-selected). Best-effort: on any failure the selector stays -// hidden and install proceeds at the catalog default. +// hidden and install proceeds at the catalog default. Only refetches when the +// cached entry is missing or past its TTL, so a repeat open inside the TTL +// paints from cache with no new RPC. async function loadInstallVersions() { - installVersions.value = [] - try { - const info = await rpcClient.getPackageVersions(appId.value) - if (!info.supportsVersions || info.versions.length < 2) return - installVersions.value = info.versions - selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || '' - } catch (err) { - if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err) + if (versionsResource.data.value === null || versionsResource.isStale.value) { + await versionsResource.refresh() } + const info = versionsResource.data.value + if (!info || !info.supportsVersions || info.versions.length < 2) { + installVersions.value = [] + return + } + installVersions.value = info.versions + selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || '' } async function loadBitcoinPruneStatus() { diff --git a/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts b/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts new file mode 100644 index 00000000..8fb238ed --- /dev/null +++ b/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts @@ -0,0 +1,328 @@ +// Per-item keyed useCachedResource conversions for secondary screens (D-04, +// plan 02-03). Pins: instant repeat-open from cache, no new RPC inside the +// TTL, per-item key isolation (rendered content, not just call counts), +// TTL-lapse revalidation, and keep-last-value on a rejected background +// refresh (D-07). + +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, type Pinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AppDetails from '../AppDetails.vue' +import MarketplaceAppDetails from '../MarketplaceAppDetails.vue' +import { rpcClient } from '@/api/rpc-client' + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + call: vi.fn(), + getPackageVersions: vi.fn(), + }, +})) + +let currentRouteParams: Record = {} +let currentRouteQuery: Record = {} + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }), + useRoute: () => ({ params: currentRouteParams, query: currentRouteQuery }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +let currentPackages: Record = {} + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + packages: currentPackages, + startPackage: vi.fn().mockResolvedValue(undefined), + stopPackage: vi.fn().mockResolvedValue(undefined), + restartPackage: vi.fn().mockResolvedValue(undefined), + updatePackage: vi.fn().mockResolvedValue(undefined), + uninstallPackage: vi.fn().mockResolvedValue(undefined), + }), +})) + +vi.mock('@/composables/useMarketplaceApp', () => ({ + useMarketplaceApp: () => ({ getCurrentApp: () => currentMarketplaceApp }), +})) + +let currentMarketplaceApp: Record | null = null + +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 makePkg(overrides: Record = {}) { + return { + manifest: { id: 'pkg', version: '1.0.0', title: 'Test App' }, + state: 'running', + health: 'healthy', + installed: null, + ...overrides, + } +} + +// A single Pinia instance is shared across the unmount/remount pairs within +// one test — matching production, where the resources store's in-memory +// entries Map is a singleton that survives navigating away from and back to +// a secondary screen (only a full page reload, or clearAll() on logout, +// resets it). Creating a fresh Pinia per mount would wipe persist:false +// entries (credentials) between "visits" and falsely fail the cache-hit +// assertions below. +function mountAppDetails(id: string, packages: Record, pinia: Pinia) { + currentRouteParams = { id } + currentRouteQuery = {} + currentPackages = packages + return mount(AppDetails, { + global: { + plugins: [pinia], + stubs: { AppHeroSection: true, AppContentSection: true, LndSeedBackup: true, AppsUninstallModal: true }, + mocks: { $ver: (v: string) => v }, + }, + }) +} + +describe('AppDetails.vue — per-item cached resources (bitcoin sync + credentials)', () => { + beforeEach(() => { + sessionStorage.clear() + vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({ + id: 'x', supportsVersions: false, default: null, installedVersion: null, + pinnedVersion: null, autoUpdate: false, versions: [], + }) + }) + afterEach(() => { + vi.useRealTimers() + }) + + function credentialsHandler(byId: Record) { + return vi.fn((req: { method: string; params?: { app_id?: string } }) => { + if (req.method === 'package.credentials') { + const appId = req.params?.app_id ?? '' + const cred = byId[appId] + return Promise.resolve(cred ? { credentials: [cred] } : { credentials: [] }) + } + if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 }) + return Promise.resolve({}) + }) + } + + it('repeat mount for the same app id inside the TTL issues exactly one credentials fetch total', async () => { + const pinia = createPinia() + const handler = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } }) + vi.mocked(rpcClient.call).mockImplementation(handler as never) + + const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + w1.unmount() + + const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + w2.unmount() + + const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials') + expect(credCalls.length).toBe(1) + }) + + it('mounting for app id alpha then beta issues two credentials fetches and never renders alpha data for beta', async () => { + const pinia = createPinia() + const handler = credentialsHandler({ + 'app-alpha': { label: 'Password', value: 'alpha-secret' }, + 'app-beta': { label: 'Password', value: 'beta-secret' }, + }) + vi.mocked(rpcClient.call).mockImplementation(handler as never) + + const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + expect(w1.text()).toContain('alpha-secret') + w1.unmount() + + const w2 = mountAppDetails('app-beta', { 'app-beta': makePkg() }, pinia) + await flushPromises() + expect(w2.text()).toContain('beta-secret') + expect(w2.text()).not.toContain('alpha-secret') + w2.unmount() + + const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials') + expect(credCalls.length).toBe(2) + }) + + it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => { + const pinia = createPinia() + vi.useFakeTimers({ shouldAdvanceTime: true }) + let credentialCallCount = 0 + + vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => { + if (req.method === 'package.credentials') { + credentialCallCount++ + return Promise.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] }) + } + if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 }) + return Promise.resolve({}) + }) + const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + w1.unmount() + + vi.advanceTimersByTime(31_000) // past the 30s TTL + + // A deferred second fetch lets us observe the cached value on-screen + // before the TTL-triggered revalidate resolves. + const stalled = deferred<{ credentials: { label: string; value: string }[] }>() + vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => { + if (req.method === 'package.credentials') { + credentialCallCount++ + return stalled.promise + } + if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 }) + return Promise.resolve({}) + }) + + const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await w2.vm.$nextTick() + // Cached value renders before the (TTL-triggered) revalidate resolves. + expect(w2.text()).toContain('alpha-secret') + + stalled.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] }) + await flushPromises() + w2.unmount() + + expect(credentialCallCount).toBe(2) + }) + + it('a rejected background refresh keeps the previously rendered credentials on screen', async () => { + const pinia = createPinia() + vi.useFakeTimers({ shouldAdvanceTime: true }) + const good = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } }) + vi.mocked(rpcClient.call).mockImplementation(good as never) + + const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + expect(w1.text()).toContain('alpha-secret') + w1.unmount() + + vi.advanceTimersByTime(31_000) + vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => { + if (req.method === 'package.credentials') return Promise.reject(new Error('offline')) + if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 }) + return Promise.resolve({}) + }) + + const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia) + await flushPromises() + // Keep-last-value (D-07): the in-memory cache still shows the previous + // credential even though the revalidate failed. + expect(w2.text()).toContain('alpha-secret') + w2.unmount() + }) + + it('bitcoin-sync and credentials fetchers for a single mount are issued concurrently, not one after another', async () => { + const pinia = createPinia() + const bitcoinDeferred = deferred<{ block_height: number; sync_progress: number }>() + const credsDeferred = deferred<{ credentials: { label: string; value: string }[] }>() + const calledMethods: string[] = [] + vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => { + calledMethods.push(req.method) + if (req.method === 'bitcoin.getinfo') return bitcoinDeferred.promise + if (req.method === 'package.credentials') return credsDeferred.promise + return Promise.resolve({}) + }) + + // 'mempool-electrs' is in BITCOIN_DEPENDENT_APPS and maps to itself via + // ROUTE_TO_PACKAGE_KEY, so this id exercises the bitcoin-sync resource. + const w = mountAppDetails('mempool-electrs', { 'mempool-electrs': makePkg() }, pinia) + await w.vm.$nextTick() + + // Both fetchers must already be in flight before either resolves — + // proof neither loader awaited the other. + expect(calledMethods).toContain('bitcoin.getinfo') + expect(calledMethods).toContain('package.credentials') + + bitcoinDeferred.resolve({ block_height: 800000, sync_progress: 1 }) + credsDeferred.resolve({ credentials: [] }) + await flushPromises() + w.unmount() + }) +}) + +describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () => { + beforeEach(() => { + sessionStorage.clear() + currentRouteQuery = {} + }) + afterEach(() => { + vi.useRealTimers() + }) + + function mountMarketplaceDetails(id: string, app: Record | null) { + currentRouteParams = { id } + currentRouteQuery = {} + currentMarketplaceApp = app + return mount(MarketplaceAppDetails, { + global: { + plugins: [createPinia()], + mocks: { $ver: (v: string) => v }, + }, + }) + } + + function makeApp(id: string, overrides: Record = {}) { + return { + id, + title: `App ${id}`, + description: 'desc', + version: '1.0.0', + dockerImage: `registry/${id}:latest`, + screenshots: [], + ...overrides, + } + } + + it('repeat mount for the same marketplace app id inside the TTL issues exactly one package.versions fetch', async () => { + const calls: string[] = [] + vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => { + calls.push(req.method) + if (req.method === 'package.versions') { + return Promise.resolve({ id: 'demo-app', supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] }) + } + return Promise.resolve({}) + }) + + const w1 = mountMarketplaceDetails('demo-app', makeApp('demo-app')) + await flushPromises() + w1.unmount() + + const w2 = mountMarketplaceDetails('demo-app', makeApp('demo-app')) + await flushPromises() + w2.unmount() + + expect(calls.filter((m) => m === 'package.versions').length).toBe(1) + }) + + it('mounting for marketplace app id alpha then beta each issue their own versions fetch (distinct per-item keys)', async () => { + const requestedIds: string[] = [] + vi.mocked(rpcClient.call).mockImplementation((req: { method: string; params?: { id?: string } }) => { + if (req.method === 'package.versions') { + requestedIds.push(req.params?.id ?? '') + return Promise.resolve({ id: req.params?.id, supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] }) + } + return Promise.resolve({}) + }) + + const w1 = mountMarketplaceDetails('mkt-alpha', makeApp('mkt-alpha')) + await flushPromises() + w1.unmount() + + const w2 = mountMarketplaceDetails('mkt-beta', makeApp('mkt-beta')) + await flushPromises() + w2.unmount() + + expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta']) + }) +})