Revert "feat: sync individual content installation states on panel" (#7154)

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

This reverts commit 9628b4c269.
This commit is contained in:
Prospector
2026-08-14 10:47:08 -07:00
committed by GitHub
parent 37a5e14657
commit 1dba3bfee8
54 changed files with 2428 additions and 2005 deletions
@@ -2,14 +2,14 @@
import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { waitForServerContextRuntimeReady } from '#ui/composables/server-context-runtime'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
@@ -18,6 +18,13 @@ import {
injectServerSettingsModal,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import {
type PendingServerContentInstall,
pendingServerContentInstallsEvent,
readPendingServerContentInstallBaseline,
readPendingServerContentInstalls,
removePendingServerContentInstall,
} from '#ui/utils/server-content-installing'
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
import type { BrowseInstallPlan } from '../../../shared/browse-tab/composables/install-logic'
@@ -110,7 +117,7 @@ const messages = defineMessages({
})
const client = injectModrinthClient()
const { server, worldId, busyReasons, installProgressItems, uploadState, cancelUpload } =
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext()
const contentUploadSession = useUploadSessionUpload({
client,
@@ -169,6 +176,7 @@ const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.
const isInstallingContent = computed(
() =>
server.value?.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
@@ -193,15 +201,6 @@ const setupActionBusyMessage = computed(() => {
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
})
const currentWorldInstallProgressItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === worldId.value),
)
const contentActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
const contentActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : null
})
const modpackProjectId = computed(() => {
const spec = contentQuery.data.value?.modpack?.spec
return spec?.platform === 'modrinth' ? spec.project_id : null
@@ -292,8 +291,6 @@ const managedContent = computed<ManagedContentData | null>(() => {
: undefined,
updatedAt: isLocal ? undefined : (mp.date_published ?? undefined),
},
disabled: setupActionDisabled.value,
disabledText: setupActionBusyMessage.value ?? formatMessage(commonMessages.installingLabel),
}
})
@@ -318,10 +315,12 @@ const addonLookup = computed(() => {
return map
})
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
const projectMetadataBatchSize = 800
const contentProjectIds = computed(() =>
[...(contentQuery.data.value?.addons ?? []), ...modpackAddons.value]
.map((addon) => addon.project_id)
.concat(pendingServerContentInstalls.value.map((item) => item.projectId))
.filter((id): id is string => !!id)
.filter((id, index, ids) => ids.indexOf(id) === index)
.sort(),
@@ -342,64 +341,43 @@ const contentProjectsQuery = useQuery({
const contentProjectsById = computed(
() => new Map((contentProjectsQuery.data.value ?? []).map((project) => [project.id, project])),
)
function normalizeInstallFilename(filename: string) {
const normalized = filename.endsWith('.disabled')
? filename.slice(0, -'.disabled'.length)
: filename
return normalized.toLowerCase()
}
type FileInstallProgressItem = Archon.Websocket.v0.InstallProgressItem & {
key: Archon.Websocket.v0.InstallProgressFileKey
}
const fileInstallProgressItems = computed<FileInstallProgressItem[]>(() =>
currentWorldInstallProgressItems.value.filter(
(item): item is FileInstallProgressItem => item.key.type === 'file',
),
const lastStableContentKeys = ref<Set<string>>(new Set())
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
const contentInstallAddedKeys = ref<Set<string>>(new Set())
const isFlushingStoredServerInstalls = ref(false)
const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn(
() => {
if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return
void contentQuery.refetch()
},
5000,
{ immediate: false },
)
function getFileInstallFilenames(key: Archon.Websocket.v0.InstallProgressFileKey) {
return [key.source_filename, key.target_filename]
.filter((filename): filename is string => !!filename)
.map(normalizeInstallFilename)
function syncPendingServerContentInstalls() {
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
}
function isFileInstallActive(item: FileInstallProgressItem) {
return item.error == null && item.progress !== 100
function handlePendingServerContentInstallsChanged(event: Event) {
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
.detail
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
}
function getContentItemInstallFilename(item: ContentItem) {
const filename = item.version?.file_name || item.file_name
return normalizeInstallFilename(filename)
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
return addon.project_id ?? addon.filename
}
function getContentItemInstallProgress(item: ContentItem): FileInstallProgressItem | undefined {
const projectId = item.project?.id
const versionId = item.version?.id
const filename = getContentItemInstallFilename(item)
return fileInstallProgressItems.value.find((progressItem) => {
const key = progressItem.key
if (key.project_id === projectId) return true
if (key.version_id === versionId) return true
return getFileInstallFilenames(key).includes(filename)
})
}
function decorateContentItemWithInstallProgress(
contentItem: ContentItem,
installProgress: FileInstallProgressItem,
): ContentItem {
return {
...contentItem,
installProgress: isFileInstallActive(installProgress) ? installProgress.progress : undefined,
function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
const keys = new Set<string>()
for (const addon of addons) {
keys.add(getAddonInstallKey(addon))
}
return keys
}
const isFlushingStoredServerInstalls = ref(false)
function getInstalledProjectIds() {
return new Set(
(contentQuery.data.value?.addons ?? [])
@@ -446,6 +424,53 @@ async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
})
}
function addonMatchesPendingInstall(
addon: Archon.Content.v1.Addon,
pendingInstall: PendingServerContentInstall,
) {
return (
addon.project_id === pendingInstall.projectId ||
addon.version?.id === pendingInstall.versionId ||
(!!pendingInstall.fileName && addon.filename === pendingInstall.fileName)
)
}
function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) {
if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return
for (const pendingInstall of pendingServerContentInstalls.value) {
if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) {
removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId)
}
}
}
function syncContentInstallKeys(
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
) {
const currentKeys = getAddonInstallKeys(addons)
if (isSyncingContent.value) {
if (!contentInstallBaselineKeys.value) {
contentInstallBaselineKeys.value =
readPendingServerContentInstallBaseline(serverId, worldId.value) ??
new Set(lastStableContentKeys.value)
}
const nextAddedKeys = new Set(contentInstallAddedKeys.value)
for (const key of currentKeys) {
if (!contentInstallBaselineKeys.value.has(key)) {
nextAddedKeys.add(key)
}
}
contentInstallAddedKeys.value = nextAddedKeys
return
}
lastStableContentKeys.value = currentKeys
contentInstallBaselineKeys.value = null
contentInstallAddedKeys.value = new Set()
}
async function flushStoredServerInstalls() {
const wid = worldId.value
if (!wid || isFlushingStoredServerInstalls.value) return
@@ -453,17 +478,6 @@ async function flushStoredServerInstalls() {
const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid)
if (queuedPlans.size === 0) return
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
addNotification({
type: 'error',
title: formatMessage(messages.failedToInstallContent),
text: error instanceof Error ? error.message : undefined,
})
return
}
isFlushingStoredServerInstalls.value = true
try {
const result = await flushStoredServerAddonInstallQueue({
@@ -478,6 +492,9 @@ async function flushStoredServerInstalls() {
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, wid, plan.projectId)
}
addNotification({
type: 'error',
title: formatMessage(messages.failedToInstallContent),
@@ -491,39 +508,224 @@ async function flushStoredServerInstalls() {
}
} finally {
isFlushingStoredServerInstalls.value = false
syncPendingServerContentInstalls()
}
}
const contentItems = computed<ContentItem[]>(() =>
(contentQuery.data.value?.addons ?? []).map((addon) => {
const contentItem = addonToContentItem(addon)
if (!contentItem.installing) return contentItem
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
const projectMetadata = contentProjectsById.value.get(item.projectId)
return {
project: {
...(projectMetadata ?? {}),
id: item.projectId,
slug: item.slug ?? projectMetadata?.slug ?? item.projectId,
title: projectMetadata?.title ?? item.title,
icon_url: item.iconUrl ?? projectMetadata?.icon_url ?? undefined,
},
version: {
id: item.versionId,
version_number:
item.versionName ?? item.versionNumber ?? formatMessage(commonMessages.installingLabel),
file_name: item.fileName ?? formatMessage(commonMessages.installingLabel),
},
owner: item.owner
? {
id: item.owner.id,
name: item.owner.name,
type: item.owner.type,
avatar_url: getContentOwnerAvatarUrl(item.owner),
link: item.owner.link,
}
: undefined,
id: `installing:${item.projectId}`,
enabled: true,
file_name: `installing:${item.projectId}`,
project_type: item.contentType,
has_update: false,
update_version_id: null,
installing: true,
}
}
const installProgress = getContentItemInstallProgress(contentItem)
return installProgress
? decorateContentItemWithInstallProgress(contentItem, installProgress)
: contentItem
}),
)
const rawContentItems = computed<ContentItem[]>(() => {
const addons = contentQuery.data.value?.addons ?? []
const pendingProjectIds = new Set(
pendingServerContentInstalls.value.map((item) => item.projectId),
)
const pendingInstallByProjectId = new Map(
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
)
const pendingInstallByVersionId = new Map(
pendingServerContentInstalls.value.map((item) => [item.versionId, item]),
)
const pendingInstallByFileName = new Map<string, PendingServerContentInstall>()
for (const item of pendingServerContentInstalls.value) {
if (item.fileName) {
pendingInstallByFileName.set(item.fileName, item)
}
}
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
const resolvedPendingProjectIds = new Set(
pendingServerContentInstalls.value
.filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item)))
.map((item) => item.projectId),
)
const pendingItems = pendingServerContentInstalls.value
.filter((item) => !resolvedPendingProjectIds.has(item.projectId))
.map(pendingInstallToContentItem)
const addonItems = addons.map((addon) => {
const contentItem = addonToContentItem(addon)
const pendingItem =
(addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ??
(addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ??
pendingInstallByFileName.get(addon.filename) ??
null
const installing = !!pendingItem || installingContentKeys.has(getAddonInstallKey(addon))
if (!installing || !pendingItem) {
return {
...contentItem,
installing,
}
}
const pendingContentItem = pendingInstallToContentItem(pendingItem)
return {
...contentItem,
project: {
...contentItem.project,
slug: pendingContentItem.project.slug,
title: pendingContentItem.project.title,
icon_url: contentItem.project.icon_url ?? pendingContentItem.project.icon_url,
},
version: {
id: pendingContentItem.version?.id ?? contentItem.version?.id ?? contentItem.file_name,
version_number:
pendingContentItem.version?.version_number ??
contentItem.version?.version_number ??
formatMessage(commonMessages.installingLabel),
file_name:
pendingContentItem.version?.file_name ??
contentItem.version?.file_name ??
contentItem.file_name,
},
owner: pendingContentItem.owner ?? contentItem.owner,
installing,
}
})
return [...addonItems, ...pendingItems]
})
const displayedContentItems = ref<ContentItem[]>([])
const contentItems = computed<ContentItem[]>(() => displayedContentItems.value)
const contentReadyPending = computed(
() =>
contentQuery.isLoading.value &&
contentQuery.data.value === undefined &&
contentItems.value.length === 0,
pendingServerContentInstalls.value.length === 0 &&
displayedContentItems.value.length === 0,
)
function getContentItemDisplayKey(item: ContentItem) {
return item.project?.id ?? item.file_name ?? item.id
}
function getContentItemId(item: ContentItem) {
return item.file_name ?? item.id
}
function mergeFragileContentItems(items: ContentItem[]) {
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
const mergedItems = displayedContentItems.value.map((item) => {
const key = getContentItemDisplayKey(item)
const nextItem = nextItems.get(key)
if (!nextItem) return item
nextItems.delete(key)
return nextItem
})
return [...mergedItems, ...nextItems.values()]
}
watch(
[
rawContentItems,
isSyncingContent,
() => contentQuery.isFetching.value,
() => contentQuery.isLoading.value,
],
([items, syncing, isFetching, isLoading]) => {
if (syncing) {
if (items.length > 0) {
displayedContentItems.value = mergeFragileContentItems(items)
}
return
}
if (items.length > 0 || (!isFetching && !isLoading)) {
displayedContentItems.value = items
}
},
{ deep: true, immediate: true },
)
watch(
[isSyncingContent, () => contentQuery.data.value?.addons],
([, addons]) => {
syncContentInstallKeys(addons ?? [])
},
{ deep: true, immediate: true },
)
watch(
[() => contentQuery.data.value?.addons, pendingServerContentInstalls],
([addons]) => {
removeResolvedPendingServerContentInstalls(addons ?? [])
},
{ deep: true, immediate: true },
)
watch(
() => pendingServerContentInstalls.value.length > 0,
(hasPendingInstalls) => {
if (hasPendingInstalls) {
resumePendingInstallPoll()
} else {
pausePendingInstallPoll()
}
},
{ immediate: true },
)
watch(
worldId,
() => {
syncPendingServerContentInstalls()
syncContentInstallKeys()
void flushStoredServerInstalls()
},
{ immediate: true },
)
onMounted(() => {
syncPendingServerContentInstalls()
void flushStoredServerInstalls()
window.addEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
})
onUnmounted(() => {
pausePendingInstallPoll()
window.removeEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
})
const deleteMutation = useMutation({
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
@@ -591,14 +793,14 @@ const toggleMutation = useMutation({
})
async function handleToggleEnabled(item: ContentItem) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await toggleMutation.mutateAsync({ addon })
}
async function handleDeleteItem(item: ContentItem) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await deleteMutation.mutateAsync({ addon })
@@ -606,7 +808,6 @@ async function handleDeleteItem(item: ContentItem) {
function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAddonRequest[] {
return items.flatMap((item) => {
if (item.installing) return []
const addon = addonLookup.value.get(item.file_name)
if (!addon) return []
return [{ filename: addon.filename, kind: addon.kind }]
@@ -614,7 +815,7 @@ function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAdd
}
async function handleBulkDelete(items: ContentItem[]) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -630,7 +831,7 @@ async function handleBulkDelete(items: ContentItem[]) {
}
async function handleBulkEnable(items: ContentItem[]) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -646,7 +847,7 @@ async function handleBulkEnable(items: ContentItem[]) {
}
async function handleBulkDisable(items: ContentItem[]) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -715,7 +916,7 @@ const currentLoader = computed(
)
function handleBrowseContent() {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const contentType = type.value
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
browseServerContent({
@@ -733,7 +934,7 @@ function handleBrowseContent() {
}
function handleUploadFiles() {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
@@ -838,14 +1039,13 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
id: addon.id ?? addon.filename,
enabled: !addon.disabled,
file_name: addon.filename,
date_added: addon.btime,
project_type: addon.kind,
has_update: !!addon.has_update,
update_version_id: addon.has_update,
environment: addon.version?.environment ?? undefined,
pack_client_retained: addon.pack_client_retained,
pack_client_depends: addon.pack_client_depends,
installing: addon.installing ?? addon.status === 'pending',
installing: addon.installing,
}
}
@@ -878,7 +1078,7 @@ async function handleViewModpackContent() {
}
async function handleModpackContentToggle(item: ContentItem) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
@@ -909,7 +1109,7 @@ async function handleModpackContentToggle(item: ContentItem) {
}
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
@@ -976,9 +1176,9 @@ async function handleModpackUnlinkConfirm() {
}
async function handleBulkUpdate(items: ContentItem[]) {
if (contentActionDisabled.value) return
if (setupActionDisabled.value) return
const addons = items
.filter((item) => item.has_update && !item.installing)
.filter((item) => item.has_update)
.map((item) => ({
filename: item.file_name,
version_id: item.update_version_id ?? undefined,
@@ -1022,7 +1222,6 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackUpdate() {
if (setupActionDisabled.value) return
const mp = contentQuery.data.value?.modpack
if (!mp || mp.spec.platform !== 'modrinth') return
@@ -1077,8 +1276,8 @@ function resetUpdateState() {
}
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
if (setupActionDisabled.value) return
if (updatingModpack.value) {
if (setupActionDisabled.value) return
pendingModpackUpdateVersion.value = selectedVersion
const mpSpec = contentQuery.data.value?.modpack?.spec
@@ -1099,7 +1298,6 @@ function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?
return
}
if (contentActionDisabled.value) return
performUpdate(selectedVersion)
}
@@ -1116,11 +1314,7 @@ function setAddonInstalling(filename: string, installing: boolean) {
}
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
if (
(updatingModpack.value && setupActionDisabled.value) ||
(!updatingModpack.value && contentActionDisabled.value)
)
return
if (setupActionDisabled.value) return
const item = updatingProject.value
if (item) {
setAddonInstalling(item.file_name, true)
@@ -1199,8 +1393,8 @@ provideContentManager({
error: computed(() => contentQuery.error.value ?? null),
managedContent,
isPackLocked: ref(false),
isBusy: contentActionDisabled,
busyMessage: contentActionBusyMessage,
isBusy: setupActionDisabled,
busyMessage: setupActionBusyMessage,
disableAddContent: computed(() => !canSetup.value),
disableAddContentTooltip: permissionDeniedMessage.value,
contentTypeLabel: type,
@@ -1276,8 +1470,8 @@ provideContentManager({
:header="formatMessage(messages.modpackContent)"
enable-toggle
show-environment-warnings
:action-disabled="contentActionDisabled"
:action-disabled-tooltip="contentActionBusyMessage ?? undefined"
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackBulkToggle($event, true)"
@bulk:disable="handleModpackBulkToggle($event, false)"
@@ -1310,10 +1504,8 @@ provideContentManager({
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
:action-disabled="updatingModpack ? setupActionDisabled : contentActionDisabled"
:action-disabled-tooltip="
(updatingModpack ? setupActionBusyMessage : contentActionBusyMessage) ?? undefined
"
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@@ -173,7 +173,7 @@
<template #actions>
<PageHeaderActions>
<PanelServerActionButton />
<PanelServerActionButton :disabled="!!installError" />
<Tooltip
theme="dismissable-prompt"
:triggers="[]"
@@ -217,6 +217,7 @@
size="xl"
label="More server options"
:options="serverMenuOptions"
:disabled="!!installError"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
@@ -243,6 +244,92 @@
: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">{{ errorTitle }}</div>
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Hosting supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<Button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</Button>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret — try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Hosting does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<Button v-if="errorLog" @click="openInstallLog"
><FileIcon />Open Installation Log</Button
>
<Button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</Button>
<Button
type="colored"
color="red"
class="whitespace-pre"
@click="openServerSettingsModal('installation')"
>
<RightArrowIcon />
Change Loader
</Button>
</div>
</div>
</div>
</div>
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown
:server-id="serverId"
@@ -272,7 +359,9 @@
<ServerPanelAdmonitions
class="mb-4 shrink-0"
@installation-retry="handleInstallationRetry"
:sync-progress="syncProgress"
:content-error="contentError"
@content-retry="handleContentRetry"
/>
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
</div>
@@ -305,11 +394,13 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
CopyIcon,
DatabaseBackupIcon,
FileIcon,
FolderOpenIcon,
IssuesIcon,
LayoutTemplateIcon,
@@ -317,6 +408,7 @@ import {
LoaderCircleIcon,
LockIcon,
MoreVerticalIcon,
RightArrowIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
TimerIcon,
@@ -326,14 +418,14 @@ import {
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
import { IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
@@ -359,10 +451,6 @@ import {
} from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import type {
ServerInstallationKey,
ServerInstallationState,
} from '#ui/composables/server-installation-tracker'
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
import { useServerPanelSync } from '#ui/composables/server-panel-sync'
import type { LogLine } from '#ui/layouts/shared/console'
@@ -375,6 +463,11 @@ 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 './[id]/onboarding.vue'
@@ -475,6 +568,12 @@ const debug = useDebugLogger('ServerManage')
const isReconnecting = ref(false)
const isLoading = ref(true)
const isMounted = ref(true)
const copied = ref(false)
const installError = ref<Error | null>(null)
const errorTitle = ref('Error')
const errorMessage = ref('An unexpected error occurred.')
const errorLog = ref('')
const errorLogFile = ref('')
const isOnboarding = computed(() => serverData.value?.flows?.intro)
const SETTINGS_HINT_KEY = 'server-panel-settings-hint-dismissed'
@@ -528,13 +627,6 @@ const worldId = computed(() => {
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const { data: serverContent } = useQuery({
queryKey: ['content', 'list', 'v1', props.serverId],
queryFn: () =>
client.archon.content_v1.getAddons(props.serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
})
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
computed(() => props.serverId),
worldId,
@@ -551,25 +643,101 @@ 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,
@@ -581,7 +749,7 @@ const {
worldId,
server: serverData,
serverFull,
content: serverContent,
isSyncingContent,
extraBusyReasons: backupsBusy,
setDisconnectedOnAuthIncorrect: false,
syncUptimeFromState: true,
@@ -912,7 +1080,7 @@ function loadTallyScript() {
document.head.appendChild(script)
}
async function handleInstallationRetry() {
async function handleContentRetry() {
if (!worldId.value) return
if (!canSetup.value) {
addNotification({
@@ -921,16 +1089,9 @@ async function handleInstallationRetry() {
})
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 : 'Failed to retry installation',
@@ -972,56 +1133,54 @@ const handleNewMod = () => {
}, 500)
}
type InstallationServerSnapshot = Pick<
Archon.Servers.v0.Server,
'loader' | 'loader_version' | 'mc_version'
>
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
let installationServerSnapshot: InstallationServerSnapshot | null = null
applyOptimisticCompletion()
installError.value = null
invalidateAfterInstall()
function applyInstallationTarget(current: ServerInstallationState) {
if (!serverData.value) return
break
}
case 'err': {
console.log('failed to install')
console.log(data)
errorTitle.value = 'Installation error'
errorMessage.value = data.reason ?? 'Unknown error'
installError.value = new Error(data.reason ?? 'Unknown error')
if (!installationServerSnapshot) {
installationServerSnapshot = {
loader: serverData.value.loader,
loader_version: serverData.value.loader_version,
mc_version: serverData.value.mc_version,
try {
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
if (files && files.total > 1) {
for (let i = 2; i <= files.total; i++) {
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100)
if (nextFiles?.items?.length === 0) break
if (nextFiles) files = nextFiles
}
}
const fileName = files?.items?.find((file) =>
file.name.startsWith('modrinth-installation'),
)?.name
errorLogFile.value = fileName ?? ''
if (fileName) {
const content = await client.kyros.files_v0.downloadFile(fileName)
errorLog.value = await content.text()
}
} catch (err) {
console.error('Failed to fetch installation log:', err)
}
break
}
}
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)
}
function restoreInstallationServerSnapshot() {
const snapshot = installationServerSnapshot
updateServerData({
...(snapshot ?? {}),
status: 'available',
})
installationServerSnapshot = null
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
const onReinstall = async (
potentialArgs: { loader?: string; lVersion?: string; mVersion?: string } | undefined,
@@ -1035,63 +1194,70 @@ const onReinstall = async (
if (!serverData.value) return
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' })
}
debug('[root.vue] onReinstall: setting serverData.status to installing')
hasSeenInstallProgress = false
updateServerData({ status: 'installing' })
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion
}
installError.value = null
errorTitle.value = 'Error'
errorMessage.value = 'An unexpected error occurred.'
modrinthServersConsole.clear()
debug('[root.vue] onReinstall: triggering immediate invalidation')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const onReinstallFailed = () => {
debug('[root.vue] onReinstallFailed: reverting status to available')
cancelOptimisticInstallation()
restoreInstallationServerSnapshot()
updateServerData({ status: 'available' })
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
function applyInstallationCompletion(key: ServerInstallationKey) {
const platformKey = key?.type === 'platform' ? key : null
function applyOptimisticCompletion() {
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
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
}
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
debug('[root.vue] applyInstallationCompletion: patch:', patch)
debug('[root.vue] applyOptimisticCompletion: 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 || !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,
})
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 })
}
}
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([
@@ -1103,48 +1269,12 @@ 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
if (current.source === 'server') return
onReinstallFailed()
void invalidateAfterInstall()
return
}
applyInstallationCompletion(current.key)
installationServerSnapshot = null
dismissInstallation(current.id)
void invalidateAfterInstall()
},
{ flush: 'sync' },
)
const nodeAccessible = ref(true)
const nodeUnavailableDetails = computed(() => [
@@ -1165,7 +1295,7 @@ const nodeUnavailableDetails = computed(() => [
label: 'Error message',
value: nodeAccessible.value
? (serverError.value?.message ?? 'Unknown')
: 'Unable to establish the node WebSocket connection.',
: 'Unable to reach node. Ping test failed.',
type: 'block' as const,
},
])
@@ -1240,6 +1370,21 @@ const nodeUnavailableAction = computed(() => ({
disabled: false,
}))
const copyServerDebugInfo = () => {
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`
navigator.clipboard.writeText(debugInfo)
copied.value = true
setTimeout(() => {
copied.value = false
}, 5000)
}
const openInstallLog = () => {
const url = `/hosting/manage/${props.serverId}/files?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 })
@@ -1283,6 +1428,48 @@ function safeStringify(obj: unknown, indent = ' '): string {
)
}
async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) return false
try {
const auth = await client.archon.servers_v0.getWebSocketAuth(props.serverId)
const authUrl = getNodeWebSocketUrl(auth.url)
const protocol = authUrl.toLowerCase().startsWith('ws://') ? 'ws' : 'wss'
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`).replace(
/^wss?:\/\//i,
`${protocol}://`,
)
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
socket.close()
resolve(false)
}, 5000)
socket.onopen = () => {
clearTimeout(timeout)
socket.send(performance.now().toString())
}
socket.onmessage = () => {
clearTimeout(timeout)
socket.close()
resolve(true)
}
socket.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
} catch (error) {
console.error(`Failed to ping node ${nodeInstance}:`, error)
return false
}
}
function initializeServer() {
if (serverData.value?.status === 'suspended') {
isLoading.value = false
@@ -1294,18 +1481,31 @@ function initializeServer() {
return
}
testNodeReachability()
.then((result) => {
nodeAccessible.value = result
if (!nodeAccessible.value) {
isLoading.value = false
}
})
.catch((err) => {
console.error('Error testing node reachability:', err)
nodeAccessible.value = false
isLoading.value = false
})
if (serverError.value) {
isLoading.value = false
} 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),
],
})
.then((connected) => {
nodeAccessible.value = connected
if (connected && cachedWsState?.consoleLines?.length) {
modrinthServersConsole.clear()
modrinthServersConsole.addLines(cachedWsState.consoleLines)
@@ -1343,6 +1543,11 @@ const cleanup = () => {
onMounted(() => {
isMounted.value = true
syncPendingServerContentInstalls()
window.addEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
if (serverData.value) {
initializeServer()
@@ -1384,6 +1589,10 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
)
cleanup()
})
</script>