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
@@ -58,6 +58,9 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
project: TProject
projectId: string
versionId: string
versionName?: string
versionNumber?: string
fileName?: string
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
@@ -579,10 +582,15 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
if (version) {
const fileName =
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
return {
project: options.project,
projectId,
versionId: version.id,
versionName: version.name,
versionNumber: version.version_number,
fileName,
contentType: options.contentType,
preferences: candidate.preferences,
source: candidate.source,
@@ -20,7 +20,6 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import Checkbox from '#ui/components/base/Checkbox.vue'
import ProgressSpinner from '#ui/components/base/ProgressSpinner.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -62,7 +61,6 @@ interface Props {
enabled?: boolean
locked?: boolean
installing?: boolean
installProgress?: number | null
hasUpdate?: boolean
isClientOnly?: boolean
clientWarning?: ClientWarningType | null
@@ -89,7 +87,6 @@ const props = withDefaults(defineProps<Props>(), {
enabled: undefined,
locked: false,
installing: false,
installProgress: undefined,
hasUpdate: false,
isClientOnly: false,
clientWarning: null,
@@ -142,11 +139,6 @@ const clientWarningMessage = computed(() => {
const { shift: shiftHeld } = useMagicKeys()
const deleteHovered = ref(false)
const installTooltip = computed(() => {
if (!props.installing) return undefined
if (props.installProgress == null) return formatMessage(commonMessages.installingLabel)
return `${formatMessage(commonMessages.installingLabel)} (${Math.round(props.installProgress)}%)`
})
</script>
<template>
@@ -170,7 +162,6 @@ const installTooltip = computed(() => {
v-if="showCheckbox"
:model-value="selected ?? false"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
:disabled="isDisabled"
class="shrink-0"
@update:model-value="(value, event) => emit('select', value, event)"
/>
@@ -179,7 +170,10 @@ const installTooltip = computed(() => {
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
>
<div v-tooltip="installTooltip" class="relative flex shrink-0 items-center">
<div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative flex shrink-0 items-center"
>
<Avatar
:src="project.icon_url"
:alt="project.title"
@@ -191,13 +185,7 @@ const installTooltip = computed(() => {
v-if="installing"
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/20"
>
<ProgressSpinner
v-if="installProgress != null && installProgress > 0"
:progress="installProgress"
:max="100"
class="size-5 text-white"
/>
<SpinnerIcon v-else class="size-5 animate-spin text-white" />
<SpinnerIcon class="size-5 animate-spin text-white" />
</div>
</div>
<div class="flex min-w-0 flex-col gap-0.5">
@@ -104,24 +104,20 @@ defineExpose({
})
// Selection logic
const selectableItems = computed(() => props.items.filter((item) => !item.disabled))
const allSelected = computed(() => {
if (selectableItems.value.length === 0) return false
return selectableItems.value.every((item) => selectedIds.value.includes(item.id))
if (props.items.length === 0) return false
return props.items.every((item) => selectedIds.value.includes(item.id))
})
const someSelected = computed(() => {
return (
selectableItems.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
)
return props.items.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
})
function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = selectableItems.value.map((item) => item.id)
selectedIds.value = props.items.map((item) => item.id)
}
}
@@ -136,10 +132,7 @@ function toggleItemSelection(
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
const rangeIds = props.items
.slice(start, end + 1)
.filter((item) => !item.disabled)
.map((item) => item.id)
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
const merged = new Set([...selectedIds.value, ...rangeIds])
selectedIds.value = [...merged]
} else if (selected) {
@@ -199,7 +192,6 @@ function handleSort(column: ContentCardTableSortColumn) {
:model-value="allSelected"
:indeterminate="someSelected"
:aria-label="formatMessage(commonMessages.selectAllLabel)"
:disabled="selectableItems.length === 0"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
@@ -277,7 +269,6 @@ function handleSort(column: ContentCardTableSortColumn) {
:enabled="item.enabled"
:locked="item.locked"
:installing="item.installing"
:install-progress="item.installProgress"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
@@ -345,7 +336,6 @@ function handleSort(column: ContentCardTableSortColumn) {
:enabled="item.enabled"
:locked="item.locked"
:installing="item.installing"
:install-progress="item.installProgress"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
@@ -434,7 +434,6 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
? (ctx.busyMessage?.value ?? null)
: base.toggleDisabledTooltip,
installing: item.installing === true,
installProgress: item.installProgress,
hasUpdate: base.hasUpdate ?? item.has_update,
isClientOnly: clientWarning !== null,
clientWarning,
@@ -67,7 +67,6 @@ export interface ContentCardTableItem {
toggleDisabledTooltip?: string | null
hideToggle?: boolean
installing?: boolean
installProgress?: number | null
hasUpdate?: boolean
isClientOnly?: boolean
clientWarning?: ClientWarningType | null
@@ -103,7 +102,6 @@ export interface ContentItem extends Omit<
pack_client_retained?: boolean
pack_client_depends?: boolean
installing?: boolean
installProgress?: number | null
source_kind?: ContentSourceKind | null
external?: boolean
external_url?: string
@@ -67,7 +67,6 @@ export function useInstallationForm(
})
const hasChanges = computed(() => {
if (ctx.requiresInstallation?.value) return true
if (selectedPlatform.value !== ctx.currentPlatform.value) return true
if (selectedGameVersion.value !== ctx.currentGameVersion.value) return true
if (
@@ -120,12 +119,8 @@ export function useInstallationForm(
isValid: isValid.value,
hasChanges: hasChanges.value,
})
if (ctx.isBusy.value || !isValid.value || !hasChanges.value) {
debug('save: ignored', {
isBusy: ctx.isBusy.value,
isValid: isValid.value,
hasChanges: hasChanges.value,
})
if (ctx.isBusy.value) {
debug('save: ignored busy')
return
}
isSaving.value = true
@@ -214,11 +209,6 @@ export function useInstallationForm(
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
if (!isValid.value) {
debug('performSave: ignored invalid form')
isSaving.value = false
return
}
try {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
@@ -24,7 +24,6 @@ export interface InstallationSettingsContext {
currentPlatform: ComputedRef<string>
currentGameVersion: ComputedRef<string>
currentLoaderVersion: ComputedRef<string>
requiresInstallation?: Ref<boolean> | ComputedRef<boolean>
availablePlatforms: string[] | ComputedRef<string[]>
@@ -74,7 +74,7 @@
</template>
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
commonMessages,
@@ -105,19 +105,9 @@ import { injectFilePicker } from '#ui/providers/file-picker'
const debug = useDebugLogger('LoaderPage')
const client = injectModrinthClient()
const {
beginInstallation,
busyReasons,
cancelOptimisticInstallation,
installation,
server,
serverId,
worldId,
} = injectModrinthServerContext()
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const serverDetailQueryKey = ['servers', 'detail', serverId] as const
const addonsQueryKey = ['content', 'list', 'v1', serverId] as const
const tags = injectTags()
const { formatMessage } = useVIntl()
const serverSettings = injectServerSettings()
@@ -210,7 +200,19 @@ const emit = defineEmits<{
'reinstall-failed': []
}>()
const isInstalling = computed(() => busyReasons.value.length > 0)
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 setupActionDisabled = computed(() => !canSetup.value || isInstalling.value)
const setupActionDisabledMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
@@ -232,24 +234,20 @@ function showResetServerModal() {
async function invalidateServerState() {
debug('invalidateServerState: starting')
await Promise.all([
queryClient.invalidateQueries({ queryKey: serverDetailQueryKey }),
queryClient.invalidateQueries({ queryKey: addonsQueryKey }),
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
])
debug('invalidateServerState: complete')
}
const addonsQuery = useQuery({
queryKey: addonsQueryKey,
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
staleTime: 30_000,
})
const requiresInstallation = computed(
() => installation.value?.status === 'failed' || addonsQuery.data.value?.error != null,
)
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
const modpackProjectId = computed(() => {
@@ -285,8 +283,6 @@ function showResetToOnboardingModal() {
}
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
const loaderGameVersionPlaceholder = '${modrinth.gameVersion}'
const minecraftServerDownloadsStartTime = Date.parse('2012-04-04T00:00:00Z')
function toApiLoaderName(loader: string): string {
return loader === 'neoforge' ? 'neo' : loader
@@ -295,14 +291,10 @@ function toApiLoaderName(loader: string): string {
const apiLoaderName = computed(() =>
modLoaders.includes(editingPlatform.value) ? toApiLoaderName(editingPlatform.value) : null,
)
const manifestFormatVersion = computed(() => (apiLoaderName.value === 'quilt' ? 1 : 0))
const manifestQuery = useQuery({
queryKey: computed(
() => ['loader-manifest', apiLoaderName.value, manifestFormatVersion.value] as const,
),
queryFn: () =>
client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!, manifestFormatVersion.value),
queryKey: computed(() => ['loader-manifest', apiLoaderName.value] as const),
queryFn: () => client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!),
enabled: computed(() => !!apiLoaderName.value),
staleTime: 5 * 60 * 1000,
})
@@ -386,101 +378,22 @@ function getLoaderVersionsForGameVersion(
const versionGroups = manifestQuery.data.value?.versionGroups
if (!manifest) return []
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) return placeholder.loaders
const entry = manifest.find((x) => x.id === gameVersion)
if (!entry) return []
if (entry?.versionGroup) {
return versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
}
const placeholder = manifest.find((x) => x.id === loaderGameVersionPlaceholder)
if (placeholder) return placeholder.loaders
return entry?.loaders ?? []
}
function supportsMinecraftServer(version: Labrinth.Tags.v2.GameVersion): boolean {
return Date.parse(version.date) >= minecraftServerDownloadsStartTime
}
function getSupportedManifestGameVersions(): Set<string> | null {
const manifest = manifestQuery.data.value?.gameVersions
if (!manifest) return null
const hasPlaceholder = manifest.some((entry) => entry.id === loaderGameVersionPlaceholder)
return new Set(
manifest
.filter((entry) => entry.id !== loaderGameVersionPlaceholder)
.filter((entry) => hasPlaceholder || entry.loaders.length > 0 || !!entry.versionGroup)
.map((entry) => entry.id),
)
}
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
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: addonsQueryKey, exact: true }),
])
const snapshot = {
server: queryClient.getQueryData<Archon.Servers.v0.Server>(serverDetailQueryKey),
addons: queryClient.getQueryData<Archon.Content.v1.Addons>(addonsQueryKey),
}
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>(addonsQueryKey, (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(addonsQueryKey, snapshot.addons)
}
async function uploadLocalModpackWithSoftOverride() {
const picked = await filePicker.pickModpackFile()
if (!picked?.file) return false
@@ -492,11 +405,8 @@ async function uploadLocalModpackWithSoftOverride() {
{ softOverride: true },
)
await uploadProgressModal.value!.track(handle)
beginInstallation({
type: 'local_modpack',
filename: picked.file.name,
})
emit('reinstall')
await invalidateServerState()
return true
}
@@ -571,37 +481,44 @@ provideInstallationSettings({
currentPlatform: computed(() => server.value?.loader?.toLowerCase() ?? 'vanilla'),
currentGameVersion: computed(() => server.value?.mc_version ?? ''),
currentLoaderVersion: computed(() => server.value?.loader_version ?? ''),
requiresInstallation,
availablePlatforms: ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur'],
editingPlatformRef: editingPlatform,
editingGameVersionRef: editingGameVersion,
resolveGameVersions(loader, showSnapshots) {
const serverVersions = tags.gameVersions.value.filter(supportsMinecraftServer)
const versions = showSnapshots
? serverVersions
: serverVersions.filter((v) => v.version_type === 'release')
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
if (loader && loader !== 'vanilla') {
if (loader === 'paper') {
const supported = paperSupportedVersionsQuery.data.value
if (!supported) return []
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
if (supported) {
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
} else if (loader === 'purpur') {
const supported = purpurSupportedVersionsQuery.data.value
if (!supported) return []
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
if (supported) {
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
} else {
const supportedVersions = getSupportedManifestGameVersions()
if (!supportedVersions) return []
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
const manifest = manifestQuery.data.value?.gameVersions
if (manifest) {
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (!hasPlaceholder) {
const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0 || !!x.versionGroup).map((x) => x.id),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
}
}
}
@@ -614,23 +531,33 @@ provideInstallationSettings({
},
resolveHasSnapshots(loader) {
const serverVersions = tags.gameVersions.value.filter(supportsMinecraftServer)
if (loader === 'vanilla') {
return serverVersions.some((v) => v.version_type !== 'release')
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
if (loader === 'paper') {
const supported = paperSupportedVersionsQuery.data.value
if (!supported) return false
return serverVersions.some((v) => v.version_type !== 'release' && supported.has(v.version))
return tags.gameVersions.value.some(
(v) => v.version_type !== 'release' && supported.has(v.version),
)
}
if (loader === 'purpur') {
const supported = purpurSupportedVersionsQuery.data.value
if (!supported) return false
return serverVersions.some((v) => v.version_type !== 'release' && supported.has(v.version))
return tags.gameVersions.value.some(
(v) => v.version_type !== 'release' && supported.has(v.version),
)
}
const supportedVersions = getSupportedManifestGameVersions()
if (!supportedVersions) return false
const supported = serverVersions.filter((v) => supportedVersions.has(v.version))
const manifest = manifestQuery.data.value?.gameVersions
if (!manifest) return false
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (hasPlaceholder) {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0 || !!x.versionGroup).map((x) => x.id),
)
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
return supported.some((v) => v.version_type !== 'release')
},
@@ -642,9 +569,6 @@ provideInstallationSettings({
const gameVersionChanged = gameVersion !== (server.value?.mc_version ?? '')
const loaderVersionChanged =
loaderVersionId !== null && loaderVersionId !== (server.value?.loader_version ?? '')
const shouldInstallContent =
requiresInstallation.value || platformChanged || loaderVersionChanged
if (!shouldInstallContent && !gameVersionChanged) return
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
@@ -652,34 +576,32 @@ provideInstallationSettings({
resolvedLoaderVersion = versions[0]?.id ?? null
}
const snapshot = await applyOptimisticInstallation(platform, gameVersion, resolvedLoaderVersion)
debug('save: emitting reinstall before API call')
emit(
'reinstall',
shouldInstallContent
platformChanged || loaderVersionChanged
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
if (shouldInstallContent) {
if (platformChanged || loaderVersionChanged) {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: resolvedLoaderVersion ?? '',
game_version: gameVersion,
game_version: gameVersion || undefined,
soft_override: true,
}
debug('save: calling installContent', request)
debug('save: platform/loader version changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else if (gameVersionChanged) {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
debug('save: succeeded')
serverSettings.closeModal?.()
debug('save: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('save: failed, emitting reinstall-failed', err)
rollbackOptimisticInstallation(snapshot)
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -692,10 +614,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')
debug('repair: API succeeded, invalidating')
await invalidateServerState()
addNotification({
type: 'success',
title: formatMessage(messages.repairStartedTitle),
@@ -703,7 +625,6 @@ provideInstallationSettings({
})
} catch (err) {
debug('repair: failed', err)
cancelOptimisticInstallation()
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
@@ -735,11 +656,6 @@ 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!, {
@@ -751,10 +667,10 @@ provideInstallationSettings({
},
soft_override: true,
})
debug('reinstallModpack: installContent succeeded')
debug('reinstallModpack: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('reinstallModpack: failed, emitting reinstall-failed', err)
cancelOptimisticInstallation()
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -804,7 +720,14 @@ provideInstallationSettings({
})
} finally {
debug('unlinkModpack: invalidating queries')
await invalidateServerState()
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['servers', 'detail', serverId],
}),
queryClient.invalidateQueries({
queryKey: ['content', 'list', 'v1', serverId],
}),
])
debug('unlinkModpack: invalidation complete')
}
},
@@ -848,11 +771,6 @@ 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!, {
@@ -864,10 +782,10 @@ provideInstallationSettings({
},
soft_override: true,
})
debug('onModpackVersionConfirm: installContent succeeded')
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
cancelOptimisticInstallation()
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -949,7 +867,6 @@ 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 = {
@@ -961,10 +878,10 @@ provideInstallationSettings({
}
debug('saveWithoutAutoFix: calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
debug('saveWithoutAutoFix: succeeded')
debug('saveWithoutAutoFix: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('saveWithoutAutoFix: failed', err)
rollbackOptimisticInstallation(snapshot)
emit('reinstall-failed')
addNotification({
type: 'error',
@@ -1015,28 +932,10 @@ watch(
)
function onReinstall(event?: unknown) {
if (resetServerDisabled.value && !installation.value) return
if (resetServerDisabled.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?.()
}
@@ -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>