Follow-up to v1.7.45-alpha closing the remaining tasks identified by the
resilience sweeps + the new bitcoin orphan / install-fail-vanish bugs.
User-visible:
- Health monitor: stop paging on orphaned containers from variant switches
- Install fail: card stays visible (was vanishing) with error message
- Stack pull progress: interpolate 20→70% (was stuck at 20%)
- docker.io → lfg2025 mirror: bitcoin/gitea/nextcloud/valkey
Internal:
- Resilience harness — install-wait uses expected_containers_for, ui+auth
probes retry with 60s backoff, dep-snapshot fix
- InstallProgress gains optional `message` field (frontend renders it
when phase is None)
binary $(stat -c %s releases/v1.7.46-alpha/archipelago) sha256:$(sha256sum releases/v1.7.46-alpha/archipelago | awk '{print $1}')
tarball $(stat -c %s releases/v1.7.46-alpha/archipelago-frontend-1.7.46-alpha.tar.gz) sha256:$(sha256sum releases/v1.7.46-alpha/archipelago-frontend-1.7.46-alpha.tar.gz | awk '{print $1}')
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
10 KiB
TypeScript
263 lines
10 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'
|
|
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<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
|
'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<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)!
|
|
// 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<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,
|
|
}
|
|
})
|