feat: persistent app install state across navigation (#9)
Move installingApps from local refs in Marketplace/Discover to the global server store. Install progress now persists when navigating between views. My Apps shows installing overlay with progress bar for apps being installed from the Marketplace. Changes: - server.ts: add installingApps Map + helpers to store - Marketplace.vue: use store's installingApps instead of local ref - Discover.vue: same - Apps.vue: pass isInstalling + installProgress to AppCard - AppCard.vue: add amber installing overlay with progress bar 522 tests pass, vue-tsc clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
840ecfaa5f
commit
2e29a41627
@ -1,13 +1,37 @@
|
|||||||
// Server store — computed server state and RPC action proxies
|
// Server store — computed server state and RPC action proxies
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useSyncStore } from './sync'
|
import { useSyncStore } from './sync'
|
||||||
|
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
||||||
|
|
||||||
export const useServerStore = defineStore('server', () => {
|
export const useServerStore = defineStore('server', () => {
|
||||||
const sync = useSyncStore()
|
const sync = useSyncStore()
|
||||||
|
|
||||||
|
// Global install tracking — persists across navigation
|
||||||
|
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
||||||
|
|
||||||
|
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||||
|
const existing = installingApps.value.get(appId)
|
||||||
|
installingApps.value.set(appId, {
|
||||||
|
status: 'downloading',
|
||||||
|
progress: 0,
|
||||||
|
message: 'Preparing...',
|
||||||
|
attempt: 0,
|
||||||
|
...existing,
|
||||||
|
...progress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInstallProgress(appId: string) {
|
||||||
|
installingApps.value.delete(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalling(appId: string): boolean {
|
||||||
|
return installingApps.value.has(appId)
|
||||||
|
}
|
||||||
|
|
||||||
// Computed — derived from sync store's data
|
// Computed — derived from sync store's data
|
||||||
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
|
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
|
||||||
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
|
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
|
||||||
@ -70,6 +94,12 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
isShuttingDown,
|
isShuttingDown,
|
||||||
isOffline,
|
isOffline,
|
||||||
|
|
||||||
|
// Install tracking (global, persists across navigation)
|
||||||
|
installingApps,
|
||||||
|
setInstallProgress,
|
||||||
|
clearInstallProgress,
|
||||||
|
isInstalling,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
installPackage,
|
installPackage,
|
||||||
uninstallPackage,
|
uninstallPackage,
|
||||||
|
|||||||
@ -101,6 +101,8 @@
|
|||||||
:index="index"
|
:index="index"
|
||||||
:show-stagger="showStagger"
|
:show-stagger="showStagger"
|
||||||
:is-loading="!!actions.loadingActions.value[id as string]"
|
:is-loading="!!actions.loadingActions.value[id as string]"
|
||||||
|
:is-installing="serverStore.isInstalling(id as string)"
|
||||||
|
:install-progress="serverStore.installingApps.get(id as string)"
|
||||||
:is-uninstalling="actions.uninstallingApps.value.has(id as string)"
|
:is-uninstalling="actions.uninstallingApps.value.has(id as string)"
|
||||||
@go-to-app="goToApp"
|
@go-to-app="goToApp"
|
||||||
@launch="launchApp"
|
@launch="launchApp"
|
||||||
@ -141,6 +143,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import type { PackageDataEntry } from '@/types/api'
|
import type { PackageDataEntry } from '@/types/api'
|
||||||
import AppCard from './apps/AppCard.vue'
|
import AppCard from './apps/AppCard.vue'
|
||||||
@ -155,6 +158,7 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const serverStore = useServerStore()
|
||||||
const actions = useAppsActions()
|
const actions = useAppsActions()
|
||||||
|
|
||||||
// Only stagger-animate on first mount
|
// Only stagger-animate on first mount
|
||||||
|
|||||||
@ -140,6 +140,7 @@ let discoverAnimationDone = false
|
|||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
@ -147,11 +148,12 @@ import DiscoverHero from './discover/DiscoverHero.vue'
|
|||||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||||
import AppGrid from './discover/AppGrid.vue'
|
import AppGrid from './discover/AppGrid.vue'
|
||||||
import FilterModal from './discover/FilterModal.vue'
|
import FilterModal from './discover/FilterModal.vue'
|
||||||
import type { MarketplaceApp, FeaturedApp, InstallProgress } from './discover/types'
|
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
|
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const serverStore = useServerStore()
|
||||||
|
|
||||||
const showStagger = !discoverAnimationDone
|
const showStagger = !discoverAnimationDone
|
||||||
const { setCurrentApp } = useMarketplaceApp()
|
const { setCurrentApp } = useMarketplaceApp()
|
||||||
@ -173,20 +175,20 @@ const categories = computed(() => [
|
|||||||
{ id: 'other', name: 'Other' }
|
{ id: 'other', name: 'Other' }
|
||||||
])
|
])
|
||||||
|
|
||||||
// Installation state
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
const installingApps = serverStore.installingApps
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
watch(() => store.packages, (packages) => {
|
watch(() => store.packages, (packages) => {
|
||||||
if (!packages) return
|
if (!packages) return
|
||||||
for (const [appId, pkg] of Object.entries(packages)) {
|
for (const [appId, pkg] of Object.entries(packages)) {
|
||||||
const progress = pkg['install-progress']
|
const progress = pkg['install-progress']
|
||||||
if (progress && pkg.state === 'installing' && installingApps.value.has(appId)) {
|
if (progress && pkg.state === 'installing' && installingApps.has(appId)) {
|
||||||
const current = installingApps.value.get(appId)!
|
const current = installingApps.get(appId)!
|
||||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
progress: Math.min(pct, 95),
|
progress: Math.min(pct, 95),
|
||||||
@ -409,50 +411,50 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function startInstallPolling(appId: string, statusMessage: string) {
|
function startInstallPolling(appId: string, statusMessage: string) {
|
||||||
const interval = trackInterval(() => {
|
const interval = trackInterval(() => {
|
||||||
const current = installingApps.value.get(appId)
|
const current = installingApps.get(appId)
|
||||||
if (!current) { clearTrackedInterval(interval); return }
|
if (!current) { clearTrackedInterval(interval); return }
|
||||||
const newAttempt = current.attempt + 1
|
const newAttempt = current.attempt + 1
|
||||||
installingApps.value.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
|
installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
|
||||||
if (isInstalled(appId)) {
|
if (isInstalled(appId)) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
|
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
||||||
} else if (newAttempt >= maxAttempts.value) {
|
} else if (newAttempt >= maxAttempts.value) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp) {
|
async function installApp(app: MarketplaceApp) {
|
||||||
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
|
||||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
|
||||||
startInstallPolling(app.id, 'Starting application...')
|
startInstallPolling(app.id, 'Starting application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp) {
|
async function installCommunityApp(app: MarketplaceApp) {
|
||||||
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||||
installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
|
||||||
try {
|
try {
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
||||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 })
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 })
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
|
||||||
startInstallPolling(app.id, 'Initializing application...')
|
startInstallPolling(app.id, 'Initializing application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
@ -119,7 +120,6 @@ import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
|||||||
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
|
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
|
||||||
import {
|
import {
|
||||||
type MarketplaceApp,
|
type MarketplaceApp,
|
||||||
type InstallProgress,
|
|
||||||
INSTALLED_ALIASES,
|
INSTALLED_ALIASES,
|
||||||
getAppTier,
|
getAppTier,
|
||||||
categorizeCommunityApp,
|
categorizeCommunityApp,
|
||||||
@ -129,6 +129,7 @@ import {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const server = useServerStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const showStagger = !marketplaceAnimationDone
|
const showStagger = !marketplaceAnimationDone
|
||||||
@ -152,8 +153,8 @@ const categories = computed(() => [
|
|||||||
{ id: 'other', name: t('marketplace.other') }
|
{ id: 'other', name: t('marketplace.other') }
|
||||||
])
|
])
|
||||||
|
|
||||||
// Installation state - support multiple concurrent installations
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
const installingApps = server.installingApps
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
// Watch WebSocket data for real install progress from backend
|
// Watch WebSocket data for real install progress from backend
|
||||||
@ -162,8 +163,8 @@ watch(() => store.packages, (packages) => {
|
|||||||
for (const [appId, pkg] of Object.entries(packages)) {
|
for (const [appId, pkg] of Object.entries(packages)) {
|
||||||
if ((pkg.state as string) === 'installing') {
|
if ((pkg.state as string) === 'installing') {
|
||||||
const progress = pkg['install-progress']
|
const progress = pkg['install-progress']
|
||||||
if (!installingApps.value.has(appId)) {
|
if (!installingApps.has(appId)) {
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
id: appId,
|
id: appId,
|
||||||
title: pkg.manifest?.title || appId,
|
title: pkg.manifest?.title || appId,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
@ -173,19 +174,19 @@ watch(() => store.packages, (packages) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (progress) {
|
if (progress) {
|
||||||
const current = installingApps.value.get(appId)!
|
const current = installingApps.get(appId)!
|
||||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
progress: Math.min(pct, 95),
|
progress: Math.min(pct, 95),
|
||||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (installingApps.value.has(appId) && (pkg.state as string) !== 'installing') {
|
} else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') {
|
||||||
installingApps.value.delete(appId)
|
installingApps.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
@ -402,11 +403,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function startInstallPolling(appId: string, statusMessage: string) {
|
function startInstallPolling(appId: string, statusMessage: string) {
|
||||||
const interval = trackInterval(() => {
|
const interval = trackInterval(() => {
|
||||||
const current = installingApps.value.get(appId)
|
const current = installingApps.get(appId)
|
||||||
if (!current) { clearTrackedInterval(interval); return }
|
if (!current) { clearTrackedInterval(interval); return }
|
||||||
|
|
||||||
const newAttempt = current.attempt + 1
|
const newAttempt = current.attempt + 1
|
||||||
installingApps.value.set(appId, {
|
installingApps.set(appId, {
|
||||||
...current,
|
...current,
|
||||||
attempt: newAttempt,
|
attempt: newAttempt,
|
||||||
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
progress: Math.min(60 + (newAttempt * 0.5), 95),
|
||||||
@ -415,49 +416,49 @@ function startInstallPolling(appId: string, statusMessage: string) {
|
|||||||
|
|
||||||
if (isInstalled(appId)) {
|
if (isInstalled(appId)) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
|
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
||||||
} else if (newAttempt >= maxAttempts.value) {
|
} else if (newAttempt >= maxAttempts.value) {
|
||||||
clearTrackedInterval(interval)
|
clearTrackedInterval(interval)
|
||||||
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
||||||
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp) {
|
async function installApp(app: MarketplaceApp) {
|
||||||
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
|
|
||||||
installingApps.value.set(app.id, {
|
installingApps.set(app.id, {
|
||||||
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
|
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||||
|
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
|
||||||
|
|
||||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
|
||||||
|
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
|
||||||
|
|
||||||
startInstallPolling(app.id, 'Starting application...')
|
startInstallPolling(app.id, 'Starting application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp) {
|
async function installCommunityApp(app: MarketplaceApp) {
|
||||||
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||||
|
|
||||||
installingApps.value.set(app.id, {
|
installingApps.set(app.id, {
|
||||||
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
|
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
||||||
|
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
method: 'package.install',
|
method: 'package.install',
|
||||||
@ -465,13 +466,13 @@ async function installCommunityApp(app: MarketplaceApp) {
|
|||||||
timeout: 180000
|
timeout: 180000
|
||||||
})
|
})
|
||||||
|
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
|
||||||
|
|
||||||
startInstallPolling(app.id, 'Initializing application...')
|
startInstallPolling(app.id, 'Initializing application...')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
|
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
|
||||||
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
||||||
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -10,6 +10,23 @@
|
|||||||
@click="$emit('goToApp', id)"
|
@click="$emit('goToApp', id)"
|
||||||
@keydown.enter="$emit('goToApp', id)"
|
@keydown.enter="$emit('goToApp', id)"
|
||||||
>
|
>
|
||||||
|
<!-- Installing overlay -->
|
||||||
|
<div
|
||||||
|
v-if="isInstalling"
|
||||||
|
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 text-amber-400">
|
||||||
|
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">{{ installProgress?.message || t('common.installing') }}...</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="installProgress" class="w-3/4 h-1 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-amber-500 rounded-full transition-all" :style="{ width: `${installProgress.progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Uninstalling overlay -->
|
<!-- Uninstalling overlay -->
|
||||||
<div
|
<div
|
||||||
v-if="isUninstalling"
|
v-if="isUninstalling"
|
||||||
@ -166,6 +183,8 @@ const props = defineProps<{
|
|||||||
index: number
|
index: number
|
||||||
showStagger: boolean
|
showStagger: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
isInstalling?: boolean
|
||||||
|
installProgress?: { status: string; progress: number; message: string }
|
||||||
isUninstalling: boolean
|
isUninstalling: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user