feat: improve handling

This commit is contained in:
Calum H. (IMB11)
2026-08-04 19:36:01 +01:00
parent 526232535e
commit 50fa5af63b
13 changed files with 811 additions and 742 deletions
@@ -1,7 +1,8 @@
<template>
<Admonition
:type="contentError ? 'critical' : 'info'"
:dismissible="dismissible"
v-if="installation"
:type="installation.status === 'failed' ? 'critical' : 'info'"
:dismissible="installation.status === 'failed'"
:progress="progressValue"
progress-color="blue"
:waiting="isWaiting"
@@ -10,23 +11,8 @@
<template #header>
{{ headerLabel }}
</template>
<template v-if="contentError">
{{ errorLabel }}
</template>
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
<div v-else class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in tickerMessages"
:key="message"
class="ticker-item"
:class="{ active: index === currentIndex % tickerMessages.length }"
>
{{ message }}
</div>
</div>
</div>
<template v-if="contentError" #top-right-actions>
{{ installation.status === 'failed' ? errorLabel : descriptionLabel }}
<template v-if="installation.status === 'failed'" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
@@ -45,29 +31,17 @@
<script setup lang="ts">
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
percent: number
}
export interface ContentError {
step: string
description: string
}
const props = defineProps<{
progress?: SyncProgress | null
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
defineProps<{
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
@@ -78,191 +52,136 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const { installation } = injectModrinthServerContext()
const messages = defineMessages({
errorHeader: {
id: 'servers.installing-banner.error.header',
defaultMessage: 'Installation failed',
},
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: "We're preparing your server",
},
invalidLoaderVersionError: {
id: 'servers.installing-banner.error.invalid-loader-version',
defaultMessage:
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
},
unsupportedLoaderVersionError: {
id: 'servers.installing-banner.error.unsupported-loader-version',
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
},
internalPlatformError: {
id: 'servers.installing-banner.error.internal-platform',
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
},
noPrimaryFileError: {
id: 'servers.installing-banner.error.no-primary-file',
defaultMessage:
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
},
modpackInstallFailedError: {
id: 'servers.installing-banner.error.modpack-install-failed',
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
},
unknownError: {
id: 'servers.installing-banner.error.unknown',
defaultMessage: 'An unexpected error occurred during installation.',
},
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: 'Preparing your server',
},
installingPlatform: {
id: 'servers.installing-banner.phase.installing-platform',
defaultMessage: 'Installing platform...',
id: 'servers.installing-banner.installing-platform',
defaultMessage: 'Installing {loader} for Minecraft {version}',
},
installingMinecraft: {
id: 'servers.installing-banner.installing-minecraft',
defaultMessage: 'Installing Minecraft {version}',
},
installingModpack: {
id: 'servers.installing-banner.phase.installing-modpack',
defaultMessage: 'Installing modpack...',
id: 'servers.installing-banner.installing-modpack',
defaultMessage: 'Installing modpack',
},
installingAddons: {
id: 'servers.installing-banner.phase.installing-addons',
defaultMessage: 'Installing addons...',
installingLocalModpack: {
id: 'servers.installing-banner.installing-local-modpack',
defaultMessage: 'Installing {filename}',
},
tickerOrganizingFiles: {
id: 'servers.installing-banner.ticker.organizing-files',
defaultMessage: 'Organizing files...',
preparingDescription: {
id: 'servers.installing-banner.description.preparing',
defaultMessage: 'Preparing your server...',
},
tickerDownloadingMods: {
id: 'servers.installing-banner.ticker.downloading-mods',
defaultMessage: 'Downloading mods...',
applyingDescription: {
id: 'servers.installing-banner.description.applying',
defaultMessage: 'Applying your installation changes...',
},
tickerConfiguringServer: {
id: 'servers.installing-banner.ticker.configuring-server',
defaultMessage: 'Configuring server...',
durationDescription: {
id: 'servers.installing-banner.description.duration',
defaultMessage: 'This installation may take several minutes...',
},
tickerSettingUpEnvironment: {
id: 'servers.installing-banner.ticker.setting-up-environment',
defaultMessage: 'Setting up environment...',
controlsDescription: {
id: 'servers.installing-banner.description.controls',
defaultMessage: 'Server controls will unlock when installation finishes.',
},
tickerAddingJava: {
id: 'servers.installing-banner.ticker.adding-java',
defaultMessage: 'Adding Java...',
stillWorkingDescription: {
id: 'servers.installing-banner.description.still-working',
defaultMessage: 'Still working—your installation is in progress...',
},
})
const errorLabel = computed(() => {
const desc = props.contentError?.description?.toLowerCase()
const step = props.contentError?.step
if (step === 'modloader') {
if (desc === 'the specified version may be incorrect') {
return formatMessage(messages.invalidLoaderVersionError)
}
if (desc === 'this version is not yet supported') {
return formatMessage(messages.unsupportedLoaderVersionError)
}
if (desc === 'internal error') {
return formatMessage(messages.internalPlatformError)
}
}
if (step === 'modpack') {
if (desc?.includes('no primary file')) {
return formatMessage(messages.noPrimaryFileError)
}
if (desc?.includes('failed to install')) {
return formatMessage(messages.modpackInstallFailedError)
}
}
return props.contentError?.description ?? formatMessage(messages.unknownError)
})
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
const headerLabel = computed(() => {
if (props.contentError) return formatMessage(messages.errorHeader)
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
return formatMessage(messages.preparingHeader)
})
const current = installation.value
if (!current) return ''
if (current.status === 'failed') return formatMessage(messages.errorHeader)
const phaseLabel = computed(() => {
switch (effectivePhase.value) {
case 'InstallingLoader':
return formatMessage(messages.installingPlatform)
case 'InstallingPack':
switch (current.key.type) {
case 'platform': {
if (current.key.platform === 'vanilla') {
return formatMessage(messages.installingMinecraft, {
version: current.key.game_version,
})
}
return formatMessage(messages.installingPlatform, {
loader: formatLoaderLabel(current.key.platform),
version: current.key.game_version,
})
}
case 'modrinth_modpack':
return formatMessage(messages.installingModpack)
case 'Addons':
return formatMessage(messages.installingAddons)
default:
return formatMessage(commonMessages.installingLabel)
case 'local_modpack':
return formatMessage(messages.installingLocalModpack, {
filename: current.key.filename,
})
case 'unknown':
return formatMessage(messages.preparingHeader)
}
})
const descriptionIndex = ref(0)
const installationId = computed(() => installation.value?.id ?? null)
watch(installationId, () => {
descriptionIndex.value = 0
})
const descriptionLabel = computed(() => {
switch (descriptionIndex.value) {
case 0:
return formatMessage(messages.preparingDescription)
case 1:
return formatMessage(messages.applyingDescription)
case 2:
return formatMessage(messages.durationDescription)
default:
return descriptionIndex.value % 2 === 1
? formatMessage(messages.controlsDescription)
: formatMessage(messages.stillWorkingDescription)
}
})
const errorLabel = computed(
() => installation.value?.error ?? formatMessage(messages.unknownError),
)
const progressValue = computed(() => {
if (props.contentError) return undefined
return props.progress ? props.progress.percent / 100 : 0
const current = installation.value
if (!current || current.status === 'failed') return undefined
return current.progress == null ? 0 : current.progress / 100
})
const isWaiting = computed(() => {
if (props.contentError) return false
return !props.progress || props.progress.percent <= 0
const current = installation.value
if (!current || current.status === 'failed') return false
return current.progress == null || current.progress <= 0
})
const tickerMessages = computed(() => [
formatMessage(messages.tickerOrganizingFiles),
formatMessage(messages.tickerDownloadingMods),
formatMessage(messages.tickerConfiguringServer),
formatMessage(messages.tickerSettingUpEnvironment),
formatMessage(messages.tickerAddingJava),
])
const currentIndex = ref(0)
let intervalId: ReturnType<typeof setInterval> | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
}, 3000)
if (installation.value?.status === 'pending' || installation.value?.status === 'installing') {
descriptionIndex.value += 1
}
}, 15_000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
if (intervalId) clearInterval(intervalId)
})
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
white-space: nowrap;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>
@@ -131,6 +131,11 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
await handleMrpackUpload(ctx.modpackFile.value, ctx.buildProperties())
} else if (ctx.setupType.value === 'modpack' && ctx.modpackSelection.value) {
debug('onFlowComplete: modpack selection path, calling installContent')
serverContext.beginInstallation({
type: 'modrinth_modpack',
project_id: ctx.modpackSelection.value.projectId,
version_id: ctx.modpackSelection.value.versionId,
})
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
@@ -159,6 +164,15 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
apiLoader: toApiLoader(loader ?? 'vanilla'),
})
serverContext.beginInstallation({
type: 'platform',
platform: (loader ?? 'vanilla') as Extract<
Archon.Websocket.v0.InstallProgressKey,
{ type: 'platform' }
>['platform'],
platform_version: loaderVersion,
game_version: ctx.selectedGameVersion.value ?? '',
})
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
@@ -183,6 +197,7 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
creationFlowRef.value?.hide()
} catch (error) {
debug('onFlowComplete: ERROR', error)
serverContext.cancelOptimisticInstallation()
if ((error as ModrinthApiError).statusCode === 429) {
addNotification({
title: formatMessage(messages.rateLimitTitle),
@@ -210,6 +225,10 @@ async function handleMrpackUpload(file: File, properties: Archon.Content.v1.Prop
{ softOverride: false },
)
await uploadProgressModal.value!.track(handle)
serverContext.beginInstallation({
type: 'local_modpack',
filename: file.name,
})
emitReinstall()
}
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import InstallingBanner, {
type ContentError,
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import InstallingBanner from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
@@ -21,13 +18,11 @@ import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
showInstanceInfo?: boolean
}>()
const emit = defineEmits<{
'content-retry': []
'installation-retry': []
}>()
const { formatMessage } = useVIntl()
@@ -75,28 +70,18 @@ const isOnContentTab = computed(
(!!route.params.instance_id && !isOnFilesTab.value && !route.path.includes('/backups')),
)
const bannerCoversInstalling = computed(
() =>
ctx.server.value?.status === 'installing' ||
ctx.isSyncingContent.value ||
ctx.busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
),
)
function isBackupReason(id: string) {
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
}
function isInstallingReason(id: string) {
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
return id === 'servers.busy.installing'
}
const filteredBusyReasons = computed(() =>
ctx.busyReasons.value.filter((r) => {
if (isBackupReason(r.reason.id)) return false
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
if (isInstallingReason(r.reason.id)) return false
return true
}),
)
@@ -112,7 +97,6 @@ const filesBusyHeader = computed(() =>
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const uploadCancelling = ref(false)
const dismissedContentErrorKey = ref<string | null>(null)
const instanceInfoAdmonitionStorageLoaded = ref(false)
const instanceInfoAdmonitionDismissed = ref(true)
const INSTANCE_INFO_ADMONITION_KEY = 'server-instances-info-admonition-dismissed'
@@ -137,16 +121,6 @@ onMounted(() => {
}
})
const contentErrorKey = computed(() =>
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
)
watch(contentErrorKey, (key) => {
if (!key) {
dismissedContentErrorKey.value = null
}
})
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
const result: BackupAdmonitionEntry[] = []
const backupById = new Map(backups.value.map((b) => [b.id, b]))
@@ -212,12 +186,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
)
const showInstallingBanner = computed(() => {
if (!ctx.server.value) return false
const installing = bannerCoversInstalling.value || !!props.contentError
if (!installing) return false
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
return false
return props.syncProgress?.phase !== 'Analyzing'
return !!ctx.installation.value && ctx.installation.value.status !== 'complete'
})
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
@@ -251,10 +220,11 @@ const stackItems = computed<ServerAdmonitionItem[]>(() => {
let sortIndex = 0
if (showInstallingBanner.value) {
const failed = ctx.installation.value?.status === 'failed'
out.push({
id: 'installing',
type: props.contentError ? 'critical' : 'info',
dismissible: !!props.contentError,
type: failed ? 'critical' : 'info',
dismissible: failed,
kind: 'installing',
priority: 0,
sortIndex: sortIndex++,
@@ -417,8 +387,8 @@ async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
if (!it.dismissible) continue
if (it.kind === 'installing' && props.contentError) {
onContentErrorDismiss()
if (it.kind === 'installing') {
onInstallationDismiss()
} else if (it.kind === 'fs-op' && it.op.id) {
const { op } = it
if (op.state === 'done' || op.state?.startsWith('fail')) {
@@ -439,9 +409,9 @@ function onFileOpDismiss(item: ServerAdmonitionItem) {
}
}
function onContentErrorDismiss() {
if (contentErrorKey.value) {
dismissedContentErrorKey.value = contentErrorKey.value
function onInstallationDismiss() {
if (ctx.installation.value) {
ctx.dismissInstallation(ctx.installation.value.id)
}
}
@@ -466,14 +436,10 @@ function onInstanceInfoDismiss() {
<template #item="{ item, dismissible }">
<InstallingBanner
v-if="item.kind === 'installing'"
:progress="syncProgress"
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
:retry-disabled="!canSetup"
:retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
@dismiss="onInstallationDismiss"
@retry="emit('installation-retry')"
/>
<UploadAdmonition
v-else-if="item.kind === 'upload'"
@@ -21,20 +21,12 @@ const powerActionMap = {
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, worldId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(
() =>
server.value.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content',
),
const isInstalling = computed(() =>
busyReasons.value.some((reason) => reason.reason.id === 'servers.busy.installing'),
)
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
@@ -0,0 +1,206 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
export type ServerInstallationKey =
| Exclude<Archon.Websocket.v0.InstallProgressKey, { type: 'file' }>
| { type: 'unknown' }
export type ServerInstallationState = {
id: string
key: ServerInstallationKey
status: 'pending' | 'installing' | 'complete' | 'failed'
progress: number | null
error: string | null
source: 'optimistic' | 'websocket' | 'server'
}
type OptimisticInstallation = {
id: string
key: ServerInstallationKey
startRevision: number
}
type UseServerInstallationTrackerOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
}
function installationKeyId(key: ServerInstallationKey) {
switch (key.type) {
case 'platform':
return `platform:${key.platform}:${key.platform_version}:${key.game_version}`
case 'modrinth_modpack':
return `modrinth-modpack:${key.project_id}:${key.version_id}`
case 'local_modpack':
return `local-modpack:${key.filename}`
case 'unknown':
return 'unknown'
}
}
function itemStatus(
item: Archon.Websocket.v0.InstallProgressItem,
): ServerInstallationState['status'] {
if (item.error != null) return 'failed'
if (item.progress === 100) return 'complete'
return 'installing'
}
export function useServerInstallationTracker(options: UseServerInstallationTrackerOptions) {
const installProgressItems = ref<Archon.Websocket.v0.InstallProgressItem[]>([])
const optimisticInstallation = ref<OptimisticInstallation | null>(null)
const receivedProgressSnapshot = ref(false)
const snapshotRevision = ref(0)
const seenActiveIds = ref(new Set<string>())
const dismissedIds = ref(new Set<string>())
let unknownInstallationId = 0
const currentWorldItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === options.worldId.value),
)
const websocketInstallation = computed<ServerInstallationState | null>(() => {
const optimistic = optimisticInstallation.value
const candidates = currentWorldItems.value.filter(
(item) => item.key.type !== 'file' && !dismissedIds.value.has(installationKeyId(item.key)),
)
for (const item of candidates) {
if (item.key.type === 'file') continue
const id = installationKeyId(item.key)
const status = itemStatus(item)
if (status === 'complete') {
if (
optimistic &&
snapshotRevision.value <= optimistic.startRevision &&
!seenActiveIds.value.has(id)
) {
continue
}
if (
!optimistic &&
!seenActiveIds.value.has(id) &&
options.server.value?.status !== 'installing'
) {
continue
}
}
return {
id,
key: item.key,
status,
progress: item.progress,
error: item.error,
source: 'websocket',
}
}
return null
})
const installation = computed<ServerInstallationState | null>(() => {
if (websocketInstallation.value) return websocketInstallation.value
const optimistic = optimisticInstallation.value
if (optimistic && !dismissedIds.value.has(optimistic.id)) {
return {
id: optimistic.id,
key: optimistic.key,
status: 'pending',
progress: null,
error: null,
source: 'optimistic',
}
}
if (options.server.value?.status !== 'installing' || receivedProgressSnapshot.value) return null
return {
id: `unknown:${unknownInstallationId}`,
key: { type: 'unknown' },
status: 'installing',
progress: null,
error: null,
source: 'server',
}
})
const isBlocking = computed(
() => installation.value?.status === 'pending' || installation.value?.status === 'installing',
)
function handleProgress(items: Archon.Websocket.v0.InstallProgressItem[]) {
snapshotRevision.value += 1
receivedProgressSnapshot.value = true
installProgressItems.value = items
const nextSeenActiveIds = new Set(seenActiveIds.value)
const nextDismissedIds = new Set(dismissedIds.value)
let hasAuthoritativeInstallation = false
for (const item of items) {
if (item.world_id !== options.worldId.value || item.key.type === 'file') continue
hasAuthoritativeInstallation = true
const id = installationKeyId(item.key)
if (item.error == null && item.progress != null && item.progress < 100) {
nextSeenActiveIds.add(id)
nextDismissedIds.delete(id)
}
}
seenActiveIds.value = nextSeenActiveIds
dismissedIds.value = nextDismissedIds
if (hasAuthoritativeInstallation) {
optimisticInstallation.value = null
}
}
function begin(key: ServerInstallationKey) {
const id =
key.type === 'unknown'
? `unknown:${++unknownInstallationId}`
: installationKeyId(key)
const nextDismissedIds = new Set(dismissedIds.value)
nextDismissedIds.delete(id)
dismissedIds.value = nextDismissedIds
optimisticInstallation.value = {
id,
key,
startRevision: snapshotRevision.value,
}
}
function cancelOptimistic() {
optimisticInstallation.value = null
}
function dismiss(id: string) {
dismissedIds.value = new Set([...dismissedIds.value, id])
if (optimisticInstallation.value?.id === id) {
optimisticInstallation.value = null
}
}
function reset() {
installProgressItems.value = []
optimisticInstallation.value = null
receivedProgressSnapshot.value = false
snapshotRevision.value = 0
seenActiveIds.value = new Set()
dismissedIds.value = new Set()
unknownInstallationId = 0
}
return {
begin,
cancelOptimistic,
dismiss,
handleProgress,
installation,
installProgressItems,
isBlocking,
reset,
}
}
@@ -11,6 +11,7 @@ import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../../providers'
import type { BusyReason, CancelUploadHandler, ServerStats } from '../../providers/server-context'
import { defineMessage } from '../i18n'
import { useServerInstallationTracker } from '../server-installation-tracker'
import { useModrinthServersConsole } from './server-console'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
@@ -26,7 +27,6 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
@@ -96,7 +96,19 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const installProgressItems = ref<Archon.Websocket.v0.InstallProgressItem[]>([])
const {
begin: beginInstallation,
cancelOptimistic: cancelOptimisticInstallation,
dismiss: dismissInstallation,
handleProgress: handleInstallProgress,
installation,
installProgressItems,
isBlocking: isInstallationBlocking,
reset: resetInstallation,
} = useServerInstallationTracker({
worldId: options.worldId,
server: options.server,
})
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
@@ -108,7 +120,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
if (isInstallationBlocking.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
@@ -116,14 +128,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}),
})
}
if (options.isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
return reasons
})
@@ -266,9 +270,9 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
startUptimeTicker()
}
const handleInstallProgress = (data: Archon.Websocket.v0.WSInstallProgressEvent) => {
const handleInstallProgressEvent = (data: Archon.Websocket.v0.WSInstallProgressEvent) => {
if (!shouldProcessEvent()) return
installProgressItems.value = data.items
handleInstallProgress(data.items)
}
const handleAuthIncorrect = () => {
@@ -307,7 +311,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
installProgressItems.value = []
resetInstallation()
}
const connectSocket = async (
@@ -331,7 +335,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
client.archon.sockets.on(targetServerId, 'state', handleState),
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
client.archon.sockets.on(targetServerId, 'install-progress', handleInstallProgress),
client.archon.sockets.on(targetServerId, 'install-progress', handleInstallProgressEvent),
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
]
@@ -413,7 +417,10 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
stats,
uptimeSeconds,
installProgressItems,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
installation,
beginInstallation,
cancelOptimisticInstallation,
dismissInstallation,
busyReasons,
fsAuth,
fsOps,
@@ -434,17 +441,21 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
return {
activeOperations,
beginInstallation,
busyReasons,
cancelUpload,
cancelOptimisticInstallation,
cleanupCoreRuntime,
connectSocket,
connectedSocketServerId,
cpuData,
disconnectSocket,
dismissInstallation,
dismissOperation,
fsAuth,
fsOps,
fsQueuedOps,
installation,
isConnected,
isServerRunning,
isWsAuthIncorrect,
@@ -49,9 +49,18 @@ import { injectFilePicker } from '#ui/providers/file-picker'
const debug = useDebugLogger('LoaderPage')
const client = injectModrinthClient()
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
const {
beginInstallation,
busyReasons,
cancelOptimisticInstallation,
installation,
server,
serverId,
worldId,
} = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const serverDetailQueryKey = ['servers', 'detail', serverId] as const
const tags = injectTags()
const { formatMessage } = useVIntl()
const serverSettings = injectServerSettings()
@@ -106,19 +115,7 @@ const emit = defineEmits<{
'reinstall-failed': []
}>()
const isInstalling = computed(() => {
const val =
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
debug(
'isInstalling:',
val,
'server.status:',
server.value?.status,
'isSyncingContent:',
isSyncingContent.value,
)
return val
})
const isInstalling = computed(() => busyReasons.value.length > 0)
const setupActionDisabled = computed(() => !canSetup.value || isInstalling.value)
const setupActionDisabledMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
@@ -297,6 +294,67 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
return loader as Archon.Content.v1.Modloader
}
type InstallationCacheSnapshot = {
server: Archon.Servers.v0.Server | undefined
addons: Archon.Content.v1.Addons | undefined
}
async function applyOptimisticInstallation(
platform: string,
gameVersion: string,
loaderVersion: string | null,
): Promise<InstallationCacheSnapshot> {
await Promise.all([
queryClient.cancelQueries({ queryKey: serverDetailQueryKey, exact: true }),
queryClient.cancelQueries({ queryKey: contentListQueryKey.value, exact: true }),
])
const snapshot = {
server: queryClient.getQueryData<Archon.Servers.v0.Server>(serverDetailQueryKey),
addons: queryClient.getQueryData<Archon.Content.v1.Addons>(contentListQueryKey.value),
}
const resolvedLoaderVersion = platform === 'vanilla' ? null : loaderVersion
beginInstallation({
type: 'platform',
platform: platform as Extract<
Archon.Websocket.v0.InstallProgressKey,
{ type: 'platform' }
>['platform'],
platform_version: resolvedLoaderVersion ?? '',
game_version: gameVersion,
})
queryClient.setQueryData<Archon.Servers.v0.Server>(serverDetailQueryKey, (current) =>
current
? {
...current,
status: 'installing',
loader: formatLoaderLabel(platform) as Archon.Servers.v0.Loader,
loader_version: resolvedLoaderVersion,
mc_version: gameVersion,
}
: current,
)
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListQueryKey.value, (current) =>
current
? {
...current,
modloader: toApiLoader(platform),
modloader_version: resolvedLoaderVersion,
game_version: gameVersion,
}
: current,
)
return snapshot
}
function rollbackOptimisticInstallation(snapshot: InstallationCacheSnapshot) {
cancelOptimisticInstallation()
queryClient.setQueryData(serverDetailQueryKey, snapshot.server)
queryClient.setQueryData(contentListQueryKey.value, snapshot.addons)
}
async function uploadLocalModpackWithSoftOverride() {
const picked = await filePicker.pickModpackFile()
if (!picked?.file) return false
@@ -308,8 +366,11 @@ async function uploadLocalModpackWithSoftOverride() {
{ softOverride: true },
)
await uploadProgressModal.value!.track(handle)
beginInstallation({
type: 'local_modpack',
filename: picked.file.name,
})
emit('reinstall')
await invalidateServerState()
return true
}
@@ -468,6 +529,7 @@ provideInstallationSettings({
const gameVersionChanged = gameVersion !== currentGameVersion.value
const loaderVersionChanged =
loaderVersionId !== null && loaderVersionId !== currentLoaderVersion.value
if (!platformChanged && !gameVersionChanged && !loaderVersionChanged) return
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
@@ -475,6 +537,11 @@ provideInstallationSettings({
resolvedLoaderVersion = versions[0]?.id ?? null
}
const snapshot = await applyOptimisticInstallation(
platform,
gameVersion,
resolvedLoaderVersion,
)
debug('save: emitting reinstall before API call')
emit(
'reinstall',
@@ -497,10 +564,10 @@ provideInstallationSettings({
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
debug('save: succeeded, invalidating')
invalidateServerState()
debug('save: succeeded')
} catch (err) {
debug('save: failed, emitting reinstall-failed', err)
rollbackOptimisticInstallation(snapshot)
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -513,10 +580,10 @@ provideInstallationSettings({
async repair() {
if (setupActionDisabled.value) return
debug('repair: called')
beginInstallation({ type: 'unknown' })
try {
await client.archon.content_v1.repair(serverId, worldId.value!)
debug('repair: API succeeded, invalidating')
await invalidateServerState()
debug('repair: API succeeded')
addNotification({
type: 'success',
title: formatMessage(messages.repairStartedTitle),
@@ -524,6 +591,7 @@ provideInstallationSettings({
})
} catch (err) {
debug('repair: failed', err)
cancelOptimisticInstallation()
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
@@ -555,6 +623,11 @@ provideInstallationSettings({
modpack.value.spec.version_id,
)
debug('reinstallModpack: emitting reinstall before API call')
beginInstallation({
type: 'modrinth_modpack',
project_id: modpack.value.spec.project_id,
version_id: modpack.value.spec.version_id,
})
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
@@ -566,10 +639,10 @@ provideInstallationSettings({
},
soft_override: true,
})
debug('reinstallModpack: installContent succeeded, invalidating')
invalidateServerState()
debug('reinstallModpack: installContent succeeded')
} catch (err) {
debug('reinstallModpack: failed, emitting reinstall-failed', err)
cancelOptimisticInstallation()
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -619,14 +692,7 @@ provideInstallationSettings({
})
} finally {
debug('unlinkModpack: invalidating queries')
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['servers', 'detail', serverId],
}),
queryClient.invalidateQueries({
queryKey: contentListQueryKey.value,
}),
])
await invalidateServerState()
debug('unlinkModpack: invalidation complete')
}
},
@@ -670,6 +736,11 @@ provideInstallationSettings({
if (!modpackProjectId.value) return
debug('onModpackVersionConfirm: called, version:', version.id)
debug('onModpackVersionConfirm: emitting reinstall before API call')
beginInstallation({
type: 'modrinth_modpack',
project_id: modpackProjectId.value,
version_id: version.id,
})
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
@@ -681,10 +752,10 @@ provideInstallationSettings({
},
soft_override: true,
})
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
invalidateServerState()
debug('onModpackVersionConfirm: installContent succeeded')
} catch (err) {
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
cancelOptimisticInstallation()
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -768,6 +839,11 @@ provideInstallationSettings({
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
const snapshot = await applyOptimisticInstallation(
platform,
gameVersion,
resolvedLoaderVersion,
)
emit('reinstall', { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion })
try {
const request: Archon.Content.v1.InstallWorldContent = {
@@ -779,10 +855,10 @@ provideInstallationSettings({
}
debug('saveWithoutAutoFix: calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
debug('saveWithoutAutoFix: succeeded, invalidating')
invalidateServerState()
debug('saveWithoutAutoFix: succeeded')
} catch (err) {
debug('saveWithoutAutoFix: failed', err)
rollbackOptimisticInstallation(snapshot)
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -843,10 +919,28 @@ watch(
)
function onReinstall(event?: unknown) {
if (resetServerDisabled.value) return
if (resetServerDisabled.value && !installation.value) return
installationSettingsLayout.value?.cancelEditing()
modrinthServersConsole.clear()
queryClient.removeQueries({ queryKey: ['servers', 'ws-state', serverId] })
if (!installation.value) {
const args = event as
| { loader?: string; lVersion?: string; mVersion?: string | null }
| undefined
if (args?.loader && args.mVersion) {
beginInstallation({
type: 'platform',
platform: args.loader as Extract<
Archon.Websocket.v0.InstallProgressKey,
{ type: 'platform' }
>['platform'],
platform_version: args.lVersion ?? '',
game_version: args.mVersion,
})
} else {
beginInstallation({ type: 'unknown' })
}
}
emit('reinstall', event)
serverSettings.closeModal?.()
}
@@ -183,10 +183,7 @@
<template #actions>
<PageHeaderActions>
<PanelServerActionButton
:disabled="!!installError"
:worlds="serverFull?.worlds ?? []"
/>
<PanelServerActionButton :worlds="serverFull?.worlds ?? []" />
<Tooltip
theme="dismissable-prompt"
:triggers="[]"
@@ -229,7 +226,6 @@
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu
:options="serverMenuOptions"
:disabled="!!installError"
aria-label="More server options"
>
<MoreVerticalIcon aria-hidden="true" />
@@ -258,85 +254,6 @@
:class="containedLayout ? 'flex min-h-0 flex-col overflow-hidden' : 'h-full'"
:style="{ '--si': 2 }"
>
<div
v-if="installError"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitleLabel }}</div>
</div>
<div v-if="errorTitle === 'installation'" class="font-normal">
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
{{ formatMessage(messages.installInvalidVersionDescription) }}
<ul class="m-0 mt-4 p-0 pl-4">
<li>
{{ formatMessage(messages.installRecentMinecraftVersionNotice) }}
</li>
<li>
{{ formatMessage(messages.installModpackCompatibilityNotice) }}
</li>
<li>
{{ formatMessage(messages.installChangeLoaderNotice) }}
</li>
<li>
{{ formatMessage(messages.installSupportNotice) }}
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
{{ formatMessage(messages.copyDebugInfo) }}
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
{{ formatMessage(messages.installInternalErrorDescription) }}
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
{{ formatMessage(messages.installUnsupportedVersionDescription) }}
</div>
<div class="mt-2 flex flex-col gap-4 sm:flex-row">
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog">
<FileIcon />
{{ formatMessage(messages.openInstallationLog) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
{{ formatMessage(messages.copyDebugInfo) }}
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<button
class="whitespace-pre"
@click="openServerInstanceSettingsModal('installation')"
>
<RightArrowIcon />
{{ formatMessage(messages.changeLoader) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown
:server-id="serverId"
@@ -366,10 +283,8 @@
<ServerPanelAdmonitions
class="mb-4 shrink-0"
:sync-progress="syncProgress"
:content-error="contentError"
:show-instance-info="showInstanceInfoAdmonition"
@content-retry="handleContentRetry"
@installation-retry="handleInstallationRetry"
/>
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
</div>
@@ -413,9 +328,7 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import {
CheckIcon,
CopyIcon,
FileIcon,
GlobeIcon,
IssuesIcon,
LayoutTemplateIcon,
@@ -423,7 +336,6 @@ import {
LoaderCircleIcon,
LockIcon,
MoreVerticalIcon,
RightArrowIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
TimerIcon,
@@ -433,7 +345,7 @@ import {
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import { useStorage } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -468,6 +380,10 @@ import {
useServerProject,
} from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type {
ServerInstallationKey,
ServerInstallationState,
} from '#ui/composables/server-installation-tracker'
import { useServerPanelSync } from '#ui/composables/server-panel-sync'
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
import { useServerManageCoreRuntime } from '#ui/composables/servers/server-manage-core-runtime.ts'
@@ -484,11 +400,6 @@ import {
import type { ServerStats } from '#ui/providers/server-context'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import {
pendingServerContentInstallsEvent,
readPendingServerContentInstalls,
writePendingServerContentInstalls,
} from '#ui/utils/server-content-installing'
import ServerOnboardingPanelPage from './onboarding.vue'
@@ -822,18 +733,6 @@ const isReconnecting = ref(false)
const isLoading = ref(true)
const isMounted = ref(true)
const showInstanceInfoAdmonition = ref(false)
const copied = ref(false)
const installError = ref<Error | null>(null)
type InstallErrorTitle = 'generic' | 'installation'
const errorTitle = ref<InstallErrorTitle>('generic')
const errorTitleLabel = computed(() =>
errorTitle.value === 'installation'
? formatMessage(messages.installationErrorTitle)
: formatMessage(messages.generalErrorTitle),
)
const errorMessage = ref(formatMessage(messages.genericErrorMessage))
const errorLog = ref('')
const errorLogFile = ref('')
const isOnboarding = computed(() => serverData.value?.flows?.intro)
const INSTANCES_HINT_KEY = 'server-panel-instances-hint-dismissed'
@@ -950,101 +849,25 @@ const { image: serverImage } = useServerImage(
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
const syncProgressActive = ref(false)
const hasPendingServerContentInstalls = ref(false)
const hasSeenPendingServerContentSync = ref(false)
const isAwaitingPostInstallRefresh = ref(false)
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
() => (syncProgressActive.value = false),
1000,
{ immediate: false },
)
watch(syncProgress, (progress) => {
if (progress != null) {
cancelSyncHide()
syncProgressActive.value = true
if (progress.phase !== 'Analyzing' && hasPendingServerContentInstalls.value) {
hasSeenPendingServerContentSync.value = true
}
} else if (syncProgressActive.value) {
startSyncHide()
if (hasSeenPendingServerContentSync.value) {
writePendingServerContentInstalls(props.serverId, worldId.value, [])
hasSeenPendingServerContentSync.value = false
}
}
})
watch(contentError, (error) => {
if (!error || !hasPendingServerContentInstalls.value) return
writePendingServerContentInstalls(props.serverId, worldId.value, [])
hasSeenPendingServerContentSync.value = false
})
const isSyncingContent = computed(
() =>
syncProgressActive.value ||
isAwaitingPostInstallRefresh.value ||
hasPendingServerContentInstalls.value,
)
function syncPendingServerContentInstalls() {
hasPendingServerContentInstalls.value =
readPendingServerContentInstalls(props.serverId, worldId.value).length > 0
}
function handlePendingServerContentInstallsChanged(event: Event) {
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
.detail
if (detail?.serverId !== props.serverId || detail?.worldId !== worldId.value) return
syncPendingServerContentInstalls()
}
watch(worldId, syncPendingServerContentInstalls, { immediate: true })
let hasSeenInstallProgress = false
const onStateEvent = (data: Archon.Websocket.v0.WSStateEvent) => {
debug('[root.vue] handleState received:', {
power_variant: data.power_variant,
progress: data.progress,
serverStatus: serverData.value?.status,
})
hasReceivedWsData.value = true
syncProgress.value = data.progress
contentError.value = data.content_error
if (serverData.value) {
if (data.progress != null && serverData.value.status !== 'installing') {
debug('[root.vue] handleState: progress != null, setting status to installing')
hasSeenInstallProgress = true
updateServerData({ status: 'installing' })
} else if (data.progress != null) {
hasSeenInstallProgress = true
} else if (
data.progress == null &&
data.content_error == null &&
serverData.value.status === 'installing' &&
hasSeenInstallProgress
) {
debug('[root.vue] handleState: progress null + was installing, applying optimistic update')
hasSeenInstallProgress = false
applyOptimisticCompletion()
invalidateAfterInstall()
}
}
}
const {
beginInstallation,
cancelUpload,
cancelOptimisticInstallation,
cleanupCoreRuntime,
connectSocket,
cpuData,
dismissInstallation,
fsOps,
fsQueuedOps,
installation,
isConnected,
ramData,
serverPowerState,
@@ -1056,7 +879,6 @@ const {
worldId,
server: serverData,
serverFull,
isSyncingContent,
extraBusyReasons: backupsBusy,
setDisconnectedOnAuthIncorrect: false,
syncUptimeFromState: true,
@@ -1398,7 +1220,7 @@ function loadTallyScript() {
document.head.appendChild(script)
}
async function handleContentRetry() {
async function handleInstallationRetry() {
if (!worldId.value) return
if (!canSetup.value) {
addNotification({
@@ -1407,9 +1229,16 @@ async function handleContentRetry() {
})
return
}
const failedInstallationId =
installation.value?.status === 'failed' ? installation.value.id : null
if (failedInstallationId) dismissInstallation(failedInstallationId)
beginInstallation({ type: 'unknown' })
updateServerData({ status: 'installing' })
try {
await client.archon.content_v1.repair(props.serverId, worldId.value)
} catch (err) {
cancelOptimisticInstallation()
updateServerData({ status: 'available' })
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRetryInstallation),
@@ -1446,57 +1275,57 @@ const handleNewMod = () => {
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
debug('[root.vue] handleInstallationResult received:', data)
switch (data.result) {
case 'ok': {
debug('[root.vue] handleInstallationResult: ok received')
if (!serverData.value) break
type InstallationServerSnapshot = Pick<
Archon.Servers.v0.Server,
'loader' | 'loader_version' | 'mc_version'
>
applyOptimisticCompletion()
installError.value = null
invalidateAfterInstall()
let installationServerSnapshot: InstallationServerSnapshot | null = null
break
}
case 'err': {
console.log('failed to install')
console.log(data)
errorTitle.value = 'installation'
errorMessage.value = data.reason ?? formatMessage(messages.unknownError)
installError.value = new Error(errorMessage.value)
function applyInstallationTarget(current: ServerInstallationState) {
if (!serverData.value) return
try {
if (!worldId.value) break
let files = await client.kyros.files_v1.listDescendants(worldId.value, '/', 1, 100)
for (let i = 2; i <= files.page_total; i++) {
const nextFiles = await client.kyros.files_v1.listDescendants(worldId.value, '/', i, 100)
if (nextFiles.items.length === 0) break
files = {
...nextFiles,
items: [...files.items, ...nextFiles.items],
}
}
const file = files.items.find((file) => file.name.startsWith('modrinth-installation'))
errorLogFile.value = file?.full_path ?? ''
if (file) {
const content = await client.kyros.files_v1.downloadRawFileContents(
worldId.value,
file.full_path,
)
errorLog.value = await content.text()
}
} catch (err) {
console.error('Failed to fetch installation log:', err)
}
break
if (!installationServerSnapshot) {
installationServerSnapshot = {
loader: serverData.value.loader,
loader_version: serverData.value.loader_version,
mc_version: serverData.value.mc_version,
}
}
const patch: Partial<Archon.Servers.v0.Server> = { status: 'installing' }
if (current.key.type === 'platform') {
patch.loader = formatLoaderLabel(current.key.platform) as Archon.Servers.v0.Loader
patch.loader_version =
current.key.platform === 'vanilla' ? null : current.key.platform_version
patch.mc_version = current.key.game_version
}
if (
serverData.value.status === patch.status &&
(current.key.type !== 'platform' ||
(serverData.value.loader === patch.loader &&
serverData.value.loader_version === patch.loader_version &&
serverData.value.mc_version === patch.mc_version))
) {
return
}
void queryClient.cancelQueries({
queryKey: ['servers', 'detail', props.serverId],
exact: true,
})
updateServerData(patch)
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
function restoreInstallationServerSnapshot() {
const snapshot = installationServerSnapshot
updateServerData({
...(snapshot ?? {}),
status: 'available',
})
installationServerSnapshot = null
}
const onReinstall = async (
potentialArgs: { loader?: string; lVersion?: string; mVersion?: string } | undefined,
@@ -1510,70 +1339,64 @@ const onReinstall = async (
if (!serverData.value) return
debug('[root.vue] onReinstall: setting serverData.status to installing')
hasSeenInstallProgress = false
updateServerData({ status: 'installing' })
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader
if (
!installation.value ||
installation.value.status === 'complete' ||
installation.value.status === 'failed'
) {
if (potentialArgs?.loader && potentialArgs.mVersion) {
beginInstallation({
type: 'platform',
platform: potentialArgs.loader as Extract<
Archon.Websocket.v0.InstallProgressKey,
{ type: 'platform' }
>['platform'],
platform_version: potentialArgs.lVersion ?? '',
game_version: potentialArgs.mVersion,
})
} else {
beginInstallation({ type: 'unknown' })
}
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion
}
installError.value = null
errorTitle.value = 'generic'
errorMessage.value = formatMessage(messages.genericErrorMessage)
modrinthServersConsole.clear()
debug('[root.vue] onReinstall: triggering immediate invalidation')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
}
const onReinstallFailed = () => {
debug('[root.vue] onReinstallFailed: reverting status to available')
updateServerData({ status: 'available' })
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
cancelOptimisticInstallation()
restoreInstallationServerSnapshot()
}
function applyOptimisticCompletion() {
function applyInstallationCompletion(key: ServerInstallationKey) {
const platformKey = key?.type === 'platform' ? key : null
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
if (newMCVersion.value) patch.mc_version = newMCVersion.value
if (platformKey) {
patch.loader = formatLoaderLabel(platformKey.platform) as Archon.Servers.v0.Loader
patch.loader_version =
platformKey.platform === 'vanilla' ? null : platformKey.platform_version
patch.mc_version = platformKey.game_version
}
debug('[root.vue] applyOptimisticCompletion: patch:', patch)
debug('[root.vue] applyInstallationCompletion: patch:', patch)
updateServerData(patch)
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
queryKey: ['content', 'list', 'v1', props.serverId],
})
for (const [key, data] of addonsQueries) {
if (!data) continue
const addonsPatch: Record<string, string> = {}
if (newLoader.value) addonsPatch.modloader = newLoader.value
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
if (Object.keys(addonsPatch).length > 0) {
queryClient.setQueryData(key, { ...data, ...addonsPatch })
}
if (!data || !platformKey) continue
queryClient.setQueryData(key, {
...data,
modloader: platformKey.platform === 'neoforge' ? 'neo_forge' : platformKey.platform,
modloader_version:
platformKey.platform === 'vanilla' ? null : platformKey.platform_version,
game_version: platformKey.game_version,
})
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
async function invalidateAfterInstall() {
debug('[root.vue] invalidateAfterInstall: scheduling 2s delayed invalidation')
isAwaitingPostInstallRefresh.value = true
setTimeout(async () => {
try {
await Promise.all([
@@ -1585,12 +1408,47 @@ async function invalidateAfterInstall() {
])
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
} finally {
isAwaitingPostInstallRefresh.value = false
}
}, 2000)
}
let handledFailedInstallationId: string | null = null
watch(
installation,
(current, previous) => {
if (!current) {
if (
isMounted.value &&
previous?.source === 'optimistic' &&
previous.status === 'pending' &&
serverData.value?.status === 'installing'
) {
restoreInstallationServerSnapshot()
}
return
}
if (current.status === 'pending' || current.status === 'installing') {
handledFailedInstallationId = null
applyInstallationTarget(current)
return
}
if (current.status === 'failed') {
if (handledFailedInstallationId === current.id) return
handledFailedInstallationId = current.id
onReinstallFailed()
void invalidateAfterInstall()
return
}
applyInstallationCompletion(current.key)
installationServerSnapshot = null
dismissInstallation(current.id)
void invalidateAfterInstall()
},
{ flush: 'sync' },
)
const nodeAccessible = ref(true)
const nodeUnavailableDetails = computed(() => [
@@ -1688,31 +1546,6 @@ const nodeUnavailableAction = computed(() => ({
disabled: false,
}))
const copyServerDebugInfo = () => {
const debugInfo = formatMessage(messages.debugInfo, {
serverId: serverData.value?.server_id ?? '',
error: errorMessage.value,
kind: serverData.value?.upstream?.kind ?? '',
projectId: serverData.value?.upstream?.project_id ?? '',
versionId: serverData.value?.upstream?.version_id ?? '',
log: errorLog.value,
})
navigator.clipboard.writeText(debugInfo)
copied.value = true
setTimeout(() => {
copied.value = false
}, 5000)
}
const openInstallLog = () => {
const filesPath = worldId.value
? `${getWorldPath(worldId.value)}/files`
: `/hosting/manage/${encodeURIComponent(props.serverId)}/instances`
const url = `${filesPath}?editing=${encodeURIComponent(errorLogFile.value)}`
window.history.pushState({}, '', url)
window.dispatchEvent(new PopStateEvent('popstate'))
}
function openServerSettingsModal(tabId?: ServerSettingsTabId) {
if (!props.serverId) return
serverSettingsModal.value?.show({ serverId: props.serverId, tabId })
@@ -1844,7 +1677,6 @@ function initializeServer() {
} else {
void connectSocket(props.serverId, {
extraSubscriptions: (targetServerId) => [
client.archon.sockets.on(targetServerId, 'installation-result', handleInstallationResult),
client.archon.sockets.on(targetServerId, 'backup-progress', handleBackupProgress),
client.archon.sockets.on(targetServerId, 'filesystem-ops', handleFilesystemOps),
client.archon.sockets.on(targetServerId, 'new-mod', handleNewMod),
@@ -1884,11 +1716,6 @@ const cleanup = () => {
onMounted(() => {
isMounted.value = true
syncPendingServerContentInstalls()
window.addEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
if (serverData.value) {
initializeServer()
@@ -1941,10 +1768,6 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
cleanup()
})
</script>
@@ -120,7 +120,6 @@ const {
serverId,
worldId,
busyReasons,
isSyncingContent,
installProgressItems,
uploadState,
cancelUpload,
@@ -188,16 +187,11 @@ const setupActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
const bannerCoversInstalling =
server.value?.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
)
busyReasons.value.some((r) => r.reason.id === 'servers.busy.installing')
const filteredReasons = busyReasons.value.filter((r) => {
if (
bannerCoversInstalling &&
(r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
r.reason.id === 'servers.busy.installing'
)
return false
if (
@@ -213,40 +207,20 @@ const setupActionBusyMessage = computed(() => {
const currentWorldInstallProgressItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === worldId.value),
)
const isIndividualContentSync = ref(false)
watch(
[currentWorldInstallProgressItems, isSyncingContent],
([items, syncing]) => {
if (!syncing) {
isIndividualContentSync.value = false
return
}
if (items.some((item) => item.key.type !== 'file')) {
isIndividualContentSync.value = false
return
}
if (items.length > 0) {
isIndividualContentSync.value = true
}
},
{ immediate: true },
const hasActiveFileInstallProgress = computed(() =>
currentWorldInstallProgressItems.value.some(
(item) =>
item.key.type === 'file' &&
item.error == null &&
item.progress != null &&
item.progress < 100,
),
)
const contentBusyReasons = computed(() => {
if (!isIndividualContentSync.value) return busyReasons.value
return busyReasons.value.filter(
(reason) =>
reason.reason.id !== 'servers.busy.installing' &&
reason.reason.id !== 'servers.busy.syncing-content',
)
})
const contentActionDisabled = computed(() => !canSetup.value || contentBusyReasons.value.length > 0)
const contentActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
const contentActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
return contentBusyReasons.value.length > 0
? formatMessage(contentBusyReasons.value[0].reason)
: null
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : null
})
const modpackProjectId = computed(() => {
@@ -471,6 +445,9 @@ function fileInstallProgressToContentItem(
}
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
const isContentInstallActive = computed(
() => hasActiveFileInstallProgress.value || pendingServerContentInstalls.value.length > 0,
)
const lastStableContentKeys = ref<Set<string>>(new Set())
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
const contentInstallAddedKeys = ref<Set<string>>(new Set())
@@ -601,7 +578,7 @@ function syncContentInstallKeys(
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
) {
const currentKeys = getAddonInstallKeys(addons)
if (isSyncingContent.value) {
if (isContentInstallActive.value) {
if (!contentInstallBaselineKeys.value) {
contentInstallBaselineKeys.value =
readPendingServerContentInstallBaseline(serverId, worldId.value) ??
@@ -855,7 +832,7 @@ function mergeFragileContentItems(items: ContentItem[]) {
watch(
[
rawContentItems,
isSyncingContent,
isContentInstallActive,
() => contentQuery.isFetching.value,
() => contentQuery.isLoading.value,
],
@@ -873,7 +850,7 @@ watch(
)
watch(
[isSyncingContent, () => contentQuery.data.value?.addons],
[isContentInstallActive, () => contentQuery.data.value?.addons],
([, addons]) => {
syncContentInstallKeys(addons ?? [])
},
+8 -3
View File
@@ -2,6 +2,10 @@ import type { Archon, UploadState } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import type { MessageDescriptor } from '#ui/composables/i18n'
import type {
ServerInstallationKey,
ServerInstallationState,
} from '#ui/composables/server-installation-tracker'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { createContext } from '.'
@@ -50,9 +54,10 @@ export interface ModrinthServerContext {
readonly stats: Ref<ServerStats>
readonly uptimeSeconds: Ref<number>
readonly installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
// Content sync state
readonly isSyncingContent: Ref<boolean>
readonly installation: ComputedRef<ServerInstallationState | null>
beginInstallation: (key: ServerInstallationKey) => void
cancelOptimisticInstallation: () => void
dismissInstallation: (id: string) => void
// Busy state — when non-empty, all write operations should be disabled
readonly busyReasons: ComputedRef<BusyReason[]>
@@ -72,7 +72,10 @@ const meta = {
stats,
uptimeSeconds: ref(0),
installProgressItems: ref<Archon.Websocket.v0.InstallProgressItem[]>([]),
isSyncingContent: ref(false),
installation: computed(() => null),
beginInstallation: () => {},
cancelOptimisticInstallation: () => {},
dismissInstallation: () => {},
busyReasons: computed(() => []),
fsAuth: ref(null),
fsOps: ref<Archon.Websocket.v0.FilesystemOperation[]>([]),
@@ -1,6 +1,85 @@
import type { Archon, UploadState } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import InstallingBanner from '../../components/servers/InstallingBanner.vue'
import type { ServerInstallationState } from '../../composables/server-installation-tracker'
import { provideModrinthServerContext } from '../../providers'
import type {
CancelUploadHandler,
ModrinthServerContext,
ServerStats,
} from '../../providers/server-context'
function renderInstallation(state: ServerInstallationState) {
return () => ({
components: { InstallingBanner },
setup() {
const installation = ref<ServerInstallationState | null>(state)
const serverContext: ModrinthServerContext = {
get serverId() {
return 'story-server'
},
worldId: ref<string | null>('story-world'),
server: ref({
server_id: 'story-server',
status: 'installing',
} as Archon.Servers.v0.Server),
serverFull: computed(() => null),
currentUserPermissions: computed(() => 0),
isConnected: ref(true),
isWsAuthIncorrect: ref(false),
powerState: ref('stopped'),
powerStateDetails: ref(undefined),
isServerRunning: computed(() => false),
stats: ref<ServerStats>({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
past: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
graph: { cpu: [], ram: [] },
}),
uptimeSeconds: ref(0),
installProgressItems: ref<Archon.Websocket.v0.InstallProgressItem[]>([]),
installation: computed(() => installation.value),
beginInstallation: () => {},
cancelOptimisticInstallation: () => {},
dismissInstallation: () => {
installation.value = null
},
busyReasons: computed(() => []),
fsAuth: ref(null),
fsOps: ref<Archon.Websocket.v0.FilesystemOperation[]>([]),
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState: ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}),
cancelUpload: ref<CancelUploadHandler | null>(null),
activeOperations: computed(() => []),
dismissOperation: async () => {},
}
provideModrinthServerContext(serverContext)
},
template: '<InstallingBanner />',
})
}
const meta = {
title: 'Servers/InstallingBanner',
@@ -10,109 +89,81 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'Default (no progress)',
}
export const WithProgress: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 45,
export const PlatformPending: Story = {
render: renderInstallation({
id: 'platform:fabric:0.16.14:1.21.1',
key: {
type: 'platform',
platform: 'fabric',
platform_version: '0.16.14',
game_version: '1.21.1',
},
},
status: 'pending',
progress: null,
error: null,
source: 'optimistic',
}),
}
export const IndeterminateLoaderInstall: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 0,
export const PlatformProgress: Story = {
render: renderInstallation({
id: 'platform:fabric:0.16.14:1.21.1',
key: {
type: 'platform',
platform: 'fabric',
platform_version: '0.16.14',
game_version: '1.21.1',
},
},
status: 'installing',
progress: 45,
error: null,
source: 'websocket',
}),
}
export const InstallingModpack: Story = {
args: {
progress: {
phase: 'InstallingPack',
percent: 72,
export const Vanilla: Story = {
render: renderInstallation({
id: 'platform:vanilla::1.21.1',
key: {
type: 'platform',
platform: 'vanilla',
platform_version: '',
game_version: '1.21.1',
},
},
status: 'installing',
progress: 20,
error: null,
source: 'websocket',
}),
}
export const InstallingAddons: Story = {
args: {
progress: {
phase: 'Addons',
percent: 90,
export const Modpack: Story = {
render: renderInstallation({
id: 'modrinth-modpack:project:version',
key: {
type: 'modrinth_modpack',
project_id: 'project',
version_id: 'version',
},
},
status: 'installing',
progress: 72,
error: null,
source: 'websocket',
}),
}
export const ErrorInvalidVersion: Story = {
name: 'Error: Invalid Version',
args: {
contentError: {
step: 'modloader',
description: 'the specified version may be incorrect',
export const Failed: Story = {
render: renderInstallation({
id: 'platform:fabric:0.16.14:1.21.1',
key: {
type: 'platform',
platform: 'fabric',
platform_version: '0.16.14',
game_version: '1.21.1',
},
},
}
export const ErrorUnsupportedVersion: Story = {
name: 'Error: Unsupported Version',
args: {
contentError: {
step: 'modloader',
description: 'this version is not yet supported',
},
},
}
export const ErrorInternal: Story = {
name: 'Error: Internal',
args: {
contentError: {
step: 'modloader',
description: 'internal error',
},
},
}
export const ErrorModpackInstall: Story = {
name: 'Error: Modpack Install Failed',
args: {
contentError: {
step: 'modpack',
description: 'Failed to install modpack',
},
},
}
export const ErrorModpackNoFile: Story = {
name: 'Error: Modpack No Primary File',
args: {
contentError: {
step: 'modpack',
description: 'Modpack version has no primary file',
},
},
}
export const AllStates: Story = {
render: () => ({
components: { InstallingBanner },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<InstallingBanner />
<InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 0 }" />
<InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 45 }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'the specified version may be incorrect' }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'this version is not yet supported' }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'internal error' }" />
<InstallingBanner :content-error="{ step: 'modpack', description: 'Failed to install modpack' }" />
</div>
`,
status: 'failed',
progress: null,
error: 'The specified version may be incorrect',
source: 'websocket',
}),
}
@@ -105,7 +105,10 @@ const meta = {
stats,
uptimeSeconds: ref(0),
installProgressItems: ref<Archon.Websocket.v0.InstallProgressItem[]>([]),
isSyncingContent: ref(false),
installation: computed(() => null),
beginInstallation: () => {},
cancelOptimisticInstallation: () => {},
dismissInstallation: () => {},
busyReasons: computed(() => [
{ reason: defineMessage({ id: 's.bg', defaultMessage: 'Background task running' }) },
]),