feat: sync individual content installation states on panel (#6909)

* feat: sync individual content installation states on panel

* feat: improve handling

* fix: lint

* fix: ws connection duplication + disconnecting during browse

* fix: sse feats

* fix: qa

* fix: qa

* fix: prepr

* fix: bug

* fix: lint
This commit is contained in:
Calum H.
2026-08-14 16:38:36 +00:00
committed by GitHub
parent 99683f570a
commit 9628b4c269
54 changed files with 2006 additions and 2429 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>
<Button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
type="outlined"
@@ -44,29 +30,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 { Button } from '#ui/components/base/buttons'
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'
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
}>()
@@ -77,191 +51,182 @@ 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",
platformErrorHeader: {
id: 'servers.installing-banner.error.header.platform',
defaultMessage: 'Failed to install {loader} {loaderVersion} for Minecraft {gameVersion}',
},
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.',
platformWithoutVersionErrorHeader: {
id: 'servers.installing-banner.error.header.platform-without-version',
defaultMessage: 'Failed to install {loader} for Minecraft {gameVersion}',
},
unsupportedLoaderVersionError: {
id: 'servers.installing-banner.error.unsupported-loader-version',
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
minecraftErrorHeader: {
id: 'servers.installing-banner.error.header.minecraft',
defaultMessage: 'Failed to install Minecraft {version}',
},
internalPlatformError: {
id: 'servers.installing-banner.error.internal-platform',
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
modpackErrorHeader: {
id: 'servers.installing-banner.error.header.modpack',
defaultMessage: 'Failed to install modpack',
},
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.',
localModpackErrorHeader: {
id: 'servers.installing-banner.error.header.local-modpack',
defaultMessage: 'Failed to install {filename}',
},
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)
const current = installation.value
if (!current) return ''
switch (current.key.type) {
case 'platform': {
if (current.key.platform === 'vanilla') {
if (current.status === 'failed') {
return formatMessage(messages.minecraftErrorHeader, {
version: current.key.game_version,
})
}
return formatMessage(messages.installingMinecraft, {
version: current.key.game_version,
})
}
if (current.status === 'failed') {
if (!current.key.platform_version) {
return formatMessage(messages.platformWithoutVersionErrorHeader, {
loader: formatLoaderLabel(current.key.platform),
gameVersion: current.key.game_version,
})
}
return formatMessage(messages.platformErrorHeader, {
loader: formatLoaderLabel(current.key.platform),
loaderVersion: current.key.platform_version,
gameVersion: current.key.game_version,
})
}
return formatMessage(messages.installingPlatform, {
loader: formatLoaderLabel(current.key.platform),
version: current.key.game_version,
})
}
case 'modrinth_modpack':
return formatMessage(
current.status === 'failed' ? messages.modpackErrorHeader : messages.installingModpack,
)
case 'local_modpack':
return formatMessage(
current.status === 'failed'
? messages.localModpackErrorHeader
: messages.installingLocalModpack,
{
filename: current.key.filename,
},
)
case 'unknown':
return formatMessage(
current.status === 'failed' ? messages.errorHeader : messages.preparingHeader,
)
}
return formatMessage(messages.preparingHeader)
})
const phaseLabel = computed(() => {
switch (effectivePhase.value) {
case 'InstallingLoader':
return formatMessage(messages.installingPlatform)
case 'InstallingPack':
return formatMessage(messages.installingModpack)
case 'Addons':
return formatMessage(messages.installingAddons)
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 formatMessage(commonMessages.installingLabel)
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, reactive, ref, watch } from 'vue'
import { computed, 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 { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
@@ -20,13 +17,8 @@ import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition
import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
}>()
const emit = defineEmits<{
'content-retry': []
'installation-retry': []
}>()
const { formatMessage } = useVIntl()
@@ -58,28 +50,18 @@ const messages = defineMessages({
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
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
}),
)
@@ -95,17 +77,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 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[] = []
@@ -171,12 +142,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'] {
@@ -210,10 +176,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++,
@@ -365,8 +332,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')) {
@@ -385,9 +352,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)
}
}
</script>
@@ -402,14 +369,10 @@ function onContentErrorDismiss() {
<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'"
@@ -13,20 +13,12 @@ export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { serverId, 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')