archy/neode-ui/src/stores/server.ts
archipelago 702b5d64d3 fix(ui): shorten install/uninstall/update timeouts for async RPCs
With the backend flipped to async-spawn, install/uninstall/update return
immediately with a { status, package_id } envelope. Client timeouts of
45m/11m were a leftover from synchronous handlers and masked real RPC
failures.

Drop all install/uninstall/update RPC timeouts to 15s. Progress and
terminal state still arrive through the live state stream — the RPC
only needs to confirm the spawn was accepted.

Return-type annotations updated in rpc-client.ts and stores/server.ts.
Five direct rpcClient.call sites across Marketplace.vue, Discover.vue,
and MarketplaceAppDetails.vue updated with the shorter timeout.
2026-04-23 06:58:02 -04:00

192 lines
6.5 KiB
TypeScript

// Server store — computed server state and RPC action proxies
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
import type { InstallProgress } from '../views/marketplace/marketplaceData'
export const useServerStore = defineStore('server', () => {
const sync = useSyncStore()
// Global install/uninstall tracking — persists across navigation
const installingApps = ref<Map<string, InstallProgress>>(new Map())
const uninstallingApps = ref<Set<string>>(new Set())
// Watch WebSocket data for real install progress — runs globally, not just on Marketplace page
watch(() => sync.packages, (packages) => {
if (!packages) return
for (const [appId, pkg] of Object.entries(packages)) {
if ((pkg.state as string) === 'installing' || (pkg.state as string) === 'updating') {
// Backend confirms it's installing — update or create tracking entry
if (!installingApps.value.has(appId)) {
installingApps.value.set(appId, {
id: appId,
title: pkg.manifest?.title || appId,
status: 'downloading',
progress: 0,
message: 'Installing...',
attempt: 0,
})
}
const progress = pkg['install-progress']
if (progress) {
const current = installingApps.value.get(appId)!
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
let message = 'Downloading...'
if (progress.size > 1024 && pct < 100) {
message = `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)`
} else if (pct >= 100 || (progress.size > 0 && progress.downloaded >= progress.size)) {
message = 'Installing package...'
}
installingApps.value.set(appId, {
...current,
status: pct >= 100 ? 'installing' : 'downloading',
progress: Math.min(pct, 95),
message,
})
}
} else if (installingApps.value.has(appId)) {
const state = pkg.state as string
// Only clear when app is fully running or definitively stopped — not during 'starting' transition
if (state === 'running' || state === 'stopped' || state === 'exited') {
installingApps.value.delete(appId)
}
}
}
// Clear installingApps entries for apps that vanished from backend data
// Only clean up entries that have errored — active installs may take minutes to pull images
for (const [appId] of installingApps.value) {
if (packages && !(appId in packages)) {
const entry = installingApps.value.get(appId)
if (entry && entry.status === 'error') {
installingApps.value.delete(appId)
}
}
}
// Clear uninstallingApps when the container disappears from backend data
for (const appId of uninstallingApps.value) {
if (packages && !(appId in packages)) {
uninstallingApps.value.delete(appId)
}
}
}, { deep: true })
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
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
const isShuttingDown = computed(() => sync.serverInfo?.['status-info']?.['shutting-down'] || false)
const isOffline = computed(() => !sync.isConnected || isRestarting.value || isShuttingDown.value)
// Package actions. install/uninstall/update are async on the backend:
// the RPC returns immediately with { status: 'installing'|'removing'|'updating',
// package_id } after flipping state, and the real work runs in a spawn.
// Progress is streamed via the WebSocket state push, not the RPC response.
async function installPackage(
id: string,
marketplaceUrl: string,
version: string,
): Promise<{ status: string; package_id: string }> {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
return rpcClient.uninstallPackage(id)
}
async function startPackage(id: string): Promise<void> {
return rpcClient.startPackage(id)
}
async function stopPackage(id: string): Promise<void> {
return rpcClient.stopPackage(id)
}
async function restartPackage(id: string): Promise<void> {
return rpcClient.restartPackage(id)
}
async function updatePackage(id: string): Promise<{ status: string; package_id: string }> {
return rpcClient.updatePackage(id)
}
// Server actions
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return rpcClient.updateServer(marketplaceUrl)
}
async function restartServer(): Promise<void> {
return rpcClient.restartServer()
}
async function shutdownServer(): Promise<void> {
return rpcClient.shutdownServer()
}
async function getMetrics(): Promise<Record<string, unknown>> {
return rpcClient.getMetrics()
}
// Marketplace actions
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
return rpcClient.getMarketplace(url)
}
function updateServerName(name: string) {
if (sync.data?.['server-info']) {
sync.data['server-info'].name = name
}
}
return {
// Computed
serverName,
isRestarting,
isShuttingDown,
isOffline,
// Install/uninstall tracking (global, persists across navigation)
installingApps,
setInstallProgress,
clearInstallProgress,
isInstalling,
uninstallingApps,
// Actions
installPackage,
uninstallPackage,
startPackage,
stopPackage,
restartPackage,
updatePackage,
updateServer,
restartServer,
shutdownServer,
getMetrics,
getMarketplace,
updateServerName,
}
})