// 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' import type { InstallPhase } from '../types/api' /** * Phase-to-UI mapping. Each backend pipeline phase maps to a fixed * progress percentage (so the bar only ever advances forward) and a * descriptive label the user can actually understand. This is the * source of truth — byte counters from `install-progress.size/downloaded` * are a fallback for the rare cases where podman does emit parseable * progress on a piped stderr. * * Percentages chosen so: * - the bar is never fully empty (users panic) * - the bar visibly advances at every phase boundary * - the slowest phases (PullingImage, WaitingHealthy) get the widest * bands so shimmer/indeterminate treatment has room * - 100% is reserved for "Done" / terminal success */ const PHASE_INFO: Record = { 'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' }, 'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' }, 'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' }, 'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' }, 'waiting-healthy': { progress: 88, message: 'Waiting for container…', status: 'starting' }, 'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' }, 'done': { progress: 100, message: 'Installed', status: 'complete' }, } export const useServerStore = defineStore('server', () => { const sync = useSyncStore() // Global install/uninstall tracking — persists across navigation const installingApps = ref>(new Map()) const uninstallingApps = ref>(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)! // Primary source: the pipeline phase. Each phase maps to a // fixed progress% and a user-facing label. if (progress.phase) { const info = PHASE_INFO[progress.phase] if (info) { // Within the PullingImage band (20→70%), interpolate the // bar based on how many images / bytes have landed so far. // Without this, multi-container stacks (indeedhub: 7, // mempool: 3, btcpay: 4) just sit at 20% for the entire // pull duration — exactly what the user reported as // "Downloading sticks at 20% mostly". X-of-N progress // comes from set_install_progress(i, n) in stacks.rs. let bandProgress = info.progress if (progress.phase === 'pulling-image' && progress.size > 0) { const fraction = Math.min(progress.downloaded / progress.size, 1) // PullingImage band: 20% → 70%, so 50pp to interpolate over. bandProgress = 20 + Math.round(fraction * 50) } // Only advance forward — never let the bar step backward // between patches (can happen briefly during scan merges). const nextProgress = Math.max(current.progress, bandProgress) // Show explicit message when set (e.g. install-fail descriptions // surfaced via install_progress.message) — otherwise PHASE_INFO label. const label = progress.message || info.message installingApps.value.set(appId, { ...current, status: info.status, progress: nextProgress, message: label, }) continue } } // No phase but message is set (install-fail path) — show the message // even if PHASE_INFO doesn't apply. Status stays whatever the watcher // currently has. if (progress.message) { installingApps.value.set(appId, { ...current, message: progress.message, }) continue } // Fallback: byte counters (rare — podman usually doesn't // emit parseable progress on a piped stderr). 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.max(current.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 & { 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 { return rpcClient.startPackage(id) } async function stopPackage(id: string): Promise { return rpcClient.stopPackage(id) } async function restartPackage(id: string): Promise { 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 { return rpcClient.restartServer() } async function shutdownServer(): Promise { return rpcClient.shutdownServer() } async function getMetrics(): Promise> { return rpcClient.getMetrics() } // Marketplace actions async function getMarketplace(url: string): Promise> { 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, } })