ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41) which broke container networking on Debian 12 targets. Rootfs already installs netavark from Debian 12 repos — just configure the backend. Install RPC now adopts existing containers (from first-boot) instead of erroring on duplicates. Container scanner extracts real versions from image tags and detects available updates against pinned versions. Frontend shows update button with version info when updates are available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
396 lines
12 KiB
Vue
396 lines
12 KiB
Vue
<template>
|
|
<div class="app-details-container pb-16 md:pb-16">
|
|
<!-- Desktop Back Button -->
|
|
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
{{ backButtonText }}
|
|
</button>
|
|
|
|
<!-- Mobile Full-Width Back Button (teleported to escape CSS transform containing block) -->
|
|
<Teleport to="body">
|
|
<button
|
|
@click="goBack"
|
|
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
<span>{{ backButtonText }}</span>
|
|
</button>
|
|
</Teleport>
|
|
|
|
<div v-if="pkg">
|
|
<AppHeroSection
|
|
:pkg="pkg"
|
|
:app-id="appId"
|
|
:package-key="packageKey"
|
|
:can-launch="canLaunch"
|
|
:is-web-only="isWebOnly"
|
|
@launch="launchApp"
|
|
@start="startApp"
|
|
@stop="stopApp"
|
|
@restart="restartApp"
|
|
@uninstall="uninstallApp"
|
|
@update="updateApp"
|
|
@channels="router.push('/dashboard/apps/lnd/channels')"
|
|
/>
|
|
|
|
<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"
|
|
/>
|
|
</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>
|
|
|
|
<!-- Uninstall Confirmation Modal -->
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="uninstallModal.show"
|
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
|
@click="closeUninstallModal()"
|
|
>
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
|
<div
|
|
ref="uninstallModalRef"
|
|
@click.stop
|
|
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
|
>
|
|
<div class="flex items-start gap-4 mb-6">
|
|
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
|
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
|
<p class="text-white/70 text-sm">
|
|
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
|
<button
|
|
@click="closeUninstallModal()"
|
|
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
@click="confirmUninstall"
|
|
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.uninstall') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- 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">×</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 { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
import { dummyApps } from '../utils/dummyApps'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
|
import AppContentSection from './appDetails/AppContentSection.vue'
|
|
import AppSidebar from './appDetails/AppSidebar.vue'
|
|
import {
|
|
WEB_ONLY_APP_URLS,
|
|
PACKAGE_ALIASES,
|
|
BITCOIN_DEPENDENT_APPS,
|
|
APP_URLS,
|
|
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('/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))
|
|
const bitcoinSyncPercent = ref(0)
|
|
const bitcoinBlockHeight = ref(0)
|
|
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadBitcoinSync()
|
|
})
|
|
|
|
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: '' })
|
|
const uninstallModalRef = ref<HTMLElement | null>(null)
|
|
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
|
|
|
|
function closeUninstallModal() {
|
|
uninstallRestoreFocusRef.value?.focus?.()
|
|
uninstallModal.value.show = false
|
|
}
|
|
|
|
useModalKeyboard(
|
|
uninstallModalRef,
|
|
computed(() => uninstallModal.value.show),
|
|
closeUninstallModal,
|
|
{ restoreFocusRef: uninstallRestoreFocusRef }
|
|
)
|
|
|
|
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 hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main)
|
|
const isRunning = pkg.value.state === 'running'
|
|
return hasUI && isRunning
|
|
})
|
|
|
|
const features = computed(() => [
|
|
'Self-hosted and privacy-focused',
|
|
'Easy installation and updates',
|
|
'Automatic backups',
|
|
'Secure by default'
|
|
])
|
|
|
|
function goBack() {
|
|
router.back()
|
|
}
|
|
|
|
function launchApp() {
|
|
if (!pkg.value) return
|
|
const isDev = import.meta.env.DEV
|
|
const id = appId.value
|
|
|
|
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
|
if (webOnlyUrl) {
|
|
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
|
return
|
|
}
|
|
|
|
if (APP_URLS[id]) {
|
|
let url = isDev ? APP_URLS[id].dev : APP_URLS[id].prod
|
|
if (url.includes('localhost')) {
|
|
url = url.replace('localhost', window.location.hostname)
|
|
}
|
|
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
|
|
return
|
|
}
|
|
|
|
const torAddress = pkg.value.manifest.interfaces?.main?.['tor-config']
|
|
const lanConfig = pkg.value.manifest.interfaces?.main?.['lan-config']
|
|
if (torAddress || lanConfig) {
|
|
showActionError(t('appDetails.noLaunchUrl'))
|
|
}
|
|
}
|
|
|
|
async function startApp() {
|
|
try {
|
|
await store.startPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
async function stopApp() {
|
|
try {
|
|
await store.stopPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
async function restartApp() {
|
|
try {
|
|
await store.restartPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
async function updateApp() {
|
|
try {
|
|
await store.updatePackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
function showUninstallModal() {
|
|
if (!pkg.value) return
|
|
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
|
}
|
|
|
|
async function confirmUninstall() {
|
|
uninstallModal.value.show = false
|
|
try {
|
|
await store.uninstallPackage(appId.value)
|
|
router.push('/dashboard/apps').catch(() => {})
|
|
} catch (err) {
|
|
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
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>
|