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