Files
archy/neode-ui/src/views/AppDetails.vue
T
archipelagoandClaude Opus 5 b839111571
Demo images / Build & push demo images (push) Successful in 3m36s
fix(02-review): audit and set explicit persist on every useCachedResource call site
Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.

persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
  Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
  cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
  peer-identity case, but a private per-user file listing; chosen false as
  the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
  Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
  persist:true to persist:false — this shared cache key's real fips.status
  response carries npub (this node's own FIPS identity key), which
  Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
  fuller FipsStatus type does; both call sites must agree since a mismatch
  trips the dev-only entry() persist-consistency warning. Found during this
  audit, not part of the originally-scoped call-site list — corrected as a
  same-class T-02-01 violation. serverTabCache.test.ts updated to match.

persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
  cloud.peer-browse (browsePeer()/loadCatalog()'s direct resources.refresh()
  calls now pass { persist: true } explicitly, matching the pre-existing
  decision already documented at peerBrowseEntry())
- Federation.vue: federation.dwn-status (sync status/counters only)
- MarketplaceAppDetails.vue: app-details:versions (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
  metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
  sibling server.* resources)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:49:16 -04:00

423 lines
14 KiB
Vue

<template>
<div class="app-details-container pb-16 md:pb-16">
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
<div v-if="pkg">
<AppHeroSection
:pkg="pkg"
:app-id="appId"
:package-key="packageKey"
:can-launch="canLaunch"
:is-web-only="isWebOnly"
:pending-action="pendingAction"
@launch="launchApp"
@start="startApp"
@stop="stopApp"
@restart="restartApp"
@uninstall="uninstallApp"
@update="updateApp"
@channels="router.push('/dashboard/apps/lnd/channels')"
/>
<LndSeedBackup v-if="packageKey === 'lnd' && pkg.installed" />
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<AppContentSection
:pkg="pkg"
:features="features"
:needs-bitcoin-sync="needsBitcoinSync"
:bitcoin-synced="bitcoinSynced"
:bitcoin-sync-percent="bitcoinSyncPercent"
:bitcoin-block-height="bitcoinBlockHeight"
/>
<AppSidebar
:pkg="pkg"
:package-key="packageKey"
:is-web-only="isWebOnly"
:gateway-state="gatewayState"
:interface-addresses="interfaceAddresses"
:lan-url="lanUrl"
:tor-url="torUrl"
:show-tor-address="showTorAddress"
:credentials="credentials"
:credentials-loading="credentialsLoading"
/>
</div>
</div>
<!-- App Not Found -->
<div v-else class="glass-card p-12 text-center">
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
</div>
<AppsUninstallModal
:show="uninstallModal.show"
:app-title="uninstallModal.appTitle"
:uninstalling="pendingAction === 'uninstall'"
@close="closeUninstallModal"
@confirm="confirmUninstall"
/>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
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'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import LndSeedBackup from './appDetails/LndSeedBackup.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { resolveAppUrl } from './appSession/appSessionConfig'
import { resolveAppCredentials } from './apps/appCredentials'
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
import {
WEB_ONLY_APP_URLS,
PACKAGE_ALIASES,
BITCOIN_DEPENDENT_APPS,
resolvePackageKey,
isRealOnionAddress,
} from './appDetails/appDetailsData'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const { t } = useI18n()
const appId = computed(() => {
const id = route.params.id
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/dashboard/apps')
return ''
}
return id
})
const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
const pkg = computed(() => {
const routeId = appId.value
const pkgKey = resolvePackageKey(routeId)
if (store.packages[pkgKey]) return store.packages[pkgKey]
if (store.packages[routeId]) return store.packages[routeId]
const aliases = PACKAGE_ALIASES[routeId]
if (aliases) {
for (const alias of aliases) {
if (store.packages[alias]) return store.packages[alias]
}
}
if (dummyApps[routeId]) return dummyApps[routeId]
return null
})
const interfaceAddresses = computed(() => {
const main = pkg.value?.installed?.['interface-addresses']?.main
if (!main) return null
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
return main
})
const lanUrl = computed(() => {
const addr = interfaceAddresses.value?.['lan-address']
if (!addr) return '#'
if (addr.includes('localhost')) return addr.replace('localhost', window.location.hostname)
return addr
})
const torUrl = computed(() => {
const addr = interfaceAddresses.value?.['tor-address']
if (!addr || !isRealOnionAddress(addr)) return ''
return addr.startsWith('http') ? addr : `http://${addr}`
})
const showTorAddress = computed(() => isRealOnionAddress(interfaceAddresses.value?.['tor-address']))
const packageKey = computed(() => resolvePackageKey(appId.value))
const gatewayState = computed(() => {
const gw = store.packages['fedimint-gateway']
return gw ? gw.state : 'not installed'
})
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
// 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
persist: true, // public chain height/sync progress — no money amount or identity
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)
// Credential material — memory-only (persist: false), never written to
// sessionStorage (D-08 / T-02-01).
const credentialsResource = useCachedResource<AppCredentialsResponse | null>({
key: `app-details:credentials:${appId.value}`,
fetcher: async (signal) => {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: packageKey.value },
signal,
dedup: true,
timeout: 5000,
})
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()
})
const actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
function showActionError(msg: string) {
actionError.value = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
const uninstallModal = ref({ show: false, appTitle: '' })
function closeUninstallModal() {
uninstallModal.value.show = false
}
const backButtonText = computed(() => {
if (route.query.from === 'discover') return 'Back to Discover'
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
return t('appDetails.backToApps')
})
const canLaunch = computed(() => {
if (!pkg.value) return false
if (isWebOnly.value) return true
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
])
function goBack() {
if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
return
}
if (route.query.from === 'marketplace') {
router.push('/dashboard/marketplace').catch(() => {})
return
}
router.push('/dashboard/apps').catch(() => {})
}
function launchApp() {
if (!pkg.value) return
const id = appId.value
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
return
}
if (isWebsitePackage(id, pkg.value)) {
const url = resolveRuntimeLaunchUrl(pkg.value)
if (url) {
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
}
return
}
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
if (runtimeUrl) {
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
return
}
// Container apps should launch through session routing so protocol/path
// handling stays centralized in appSessionConfig.
useAppLauncherStore().openSession(id)
}
async function startApp() {
pendingAction.value = 'start'
try {
await store.startPackage(appId.value)
} catch (err) {
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
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 {
pendingAction.value = null
}
}
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 {
pendingAction.value = null
}
}
async function updateApp() {
pendingAction.value = 'update'
try {
await store.updatePackage(appId.value)
} catch (err) {
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
function showUninstallModal() {
if (!pkg.value) return
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
}
async function confirmUninstall(deleteAppData: boolean) {
uninstallModal.value.show = false
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'}`)
} finally {
pendingAction.value = null
}
}
function uninstallApp() {
showUninstallModal()
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>