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
@@ -9,7 +9,6 @@ export type WebSocketEventHandler<
export interface WebSocketConnection {
serverId: string
socket: WebSocket
authenticated: boolean
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
@@ -32,7 +31,6 @@ export abstract class AbstractWebSocketClient {
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
protected readonly AUTHENTICATION_TIMEOUT = 30000
constructor(
protected client: {
@@ -60,7 +58,6 @@ export abstract class AbstractWebSocketClient {
}
if (status && !status.connected && !options?.force) {
await this.waitForAuthentication(serverId)
return
}
@@ -72,28 +69,6 @@ export abstract class AbstractWebSocketClient {
await this.connect(serverId, auth)
}
protected async waitForAuthentication(serverId: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {}
const timeout = setTimeout(() => {
unsubscribe()
reject(new Error(`WebSocket authentication timed out for server ${serverId}`))
}, this.AUTHENTICATION_TIMEOUT)
unsubscribe = this.on(serverId, 'auth-ok', () => {
clearTimeout(timeout)
unsubscribe()
resolve()
})
if (this.getStatus(serverId)?.connected) {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
@@ -113,7 +88,7 @@ export abstract class AbstractWebSocketClient {
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated,
connected: connection.socket.readyState === WebSocket.OPEN,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
@@ -301,24 +301,13 @@ export namespace Archon {
environment?: Labrinth.Projects.v3.Environment | null
}
export type AddonStatus =
| 'pending'
| 'installed'
| {
failed: {
error: string
}
}
export type Addon = {
id: string
filename: string
filesize: number
btime?: string
disabled: boolean
kind: AddonKind
from_modpack: boolean
status: AddonStatus
pack_client_retained: boolean
pack_client_depends: boolean
has_update: string | null
@@ -334,10 +323,6 @@ export namespace Archon {
modloader_version: string | null
game_version: string | null
modpack: ModpackFields | null
installing?: 'loader' | 'modpack'
error?: {
message: string
}
addons: Addon[] | null
}
@@ -1004,78 +989,6 @@ export namespace Archon {
world_id: string
spec: Archon.Content.v1.Addons
}
export type WorldContentPlatform =
| 'forge'
| 'neoforge'
| 'fabric'
| 'quilt'
| 'paper'
| 'purpur'
| 'vanilla'
export type WorldContentPlatformData = {
platform: WorldContentPlatform
game_version: string
platform_version: string | null
}
export type WorldContentModpackSource =
| 'CurseForge'
| {
Modrinth: {
version_id: string
project_id: string
mrpack_sha1: string | null
}
}
| {
LocalMrPackFile: {
path: string
name: string
description: string | null
version_name: string | null
}
}
export type WorldContentModpack = {
spec: WorldContentModpackSource
downloads: number | null
followers: number | null
icon_url: string | null
owner: Archon.Content.v1.ContentOwner | null
title: string | null
description: string | null
version_number: string | null
date_published: string | null
environment: Labrinth.Projects.v3.Environment | null
has_update: string | null
}
export type WorldContentItem = {
parent_directory: string
file_sha1: string | null
filename: string
btime?: string
from_modpack: boolean
version_id: string | null
project_id: string | null
pack_client_retained: boolean
pack_client_depends: boolean
status: Archon.Content.v1.AddonStatus
filesize: number | null
name: string | null
version: Archon.Content.v1.AddonVersion | null
owner: Archon.Content.v1.ContentOwner | null
has_update: string | null
icon_url: string | null
}
export type WorldContentUpdateEvent = {
type: 'world.content.update'
world_id: string
platform_data: WorldContentPlatformData | null
linked_modpack: WorldContentModpack | null
installing?: 'loader' | 'modpack'
error?: {
message: string
}
content: WorldContentItem[]
}
export type SyncEvent =
| ProtocolResetEvent
@@ -1094,7 +1007,6 @@ export namespace Archon {
| WorldStartupPatchEvent
| WorldContentAddonPatchEvent
| WorldContentBaseUpdateEvent
| WorldContentUpdateEvent
}
}
@@ -1199,53 +1111,6 @@ export namespace Archon {
version_id: string
}
export type InstallProgressFileKey = {
type: 'file'
install_type: 'install' | 'update'
project_id: string
version_id: string
parent_directory: string
source_filename: string | null
target_filename?: string | null
}
export type InstallProgressModrinthModpackKey = {
type: 'modrinth_modpack'
project_id: string
version_id: string
}
export type InstallProgressLocalModpackKey = {
type: 'local_modpack'
filename: string
}
export type InstallProgressPlatformKey = {
type: 'platform'
platform: 'forge' | 'neoforge' | 'fabric' | 'quilt' | 'paper' | 'purpur' | 'vanilla'
platform_version: string
game_version: string
}
export type InstallProgressKey =
| InstallProgressFileKey
| InstallProgressModrinthModpackKey
| InstallProgressLocalModpackKey
| InstallProgressPlatformKey
export type InstallProgressItem = {
world_id: string
key: InstallProgressKey
id: string
progress: number | null
error: string | null
}
export type WSInstallProgressEvent = {
event: 'install-progress'
items: InstallProgressItem[]
}
export type FilesystemOpKind = 'unarchive'
export type FilesystemOpState =
@@ -1343,7 +1208,6 @@ export namespace Archon {
| WSInstallationResultEvent
| WSUptimeEvent
| WSNewModEvent
| WSInstallProgressEvent
| WSFilesystemOpsEvent
export type WSEventType = WSEvent['event']
@@ -19,60 +19,36 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
return new Promise((resolve, reject) => {
let settled = false
let authenticationTimeout: ReturnType<typeof setTimeout> | null = null
const resolveConnection = () => {
if (settled) return
settled = true
if (authenticationTimeout) clearTimeout(authenticationTimeout)
resolve()
}
const rejectConnection = (error: unknown) => {
if (settled) return
settled = true
if (authenticationTimeout) clearTimeout(authenticationTimeout)
reject(error)
}
try {
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
const connection: WebSocketConnection = {
serverId,
socket: ws,
authenticated: false,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
}
this.connections.set(serverId, connection)
authenticationTimeout = setTimeout(() => {
rejectConnection(new Error(`WebSocket authentication timed out for server ${serverId}`))
if (this.connections.get(serverId) === connection) this.closeConnection(serverId)
}, this.AUTHENTICATION_TIMEOUT)
ws.onopen = () => {
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
if (data.event === 'auth-ok') {
connection.authenticated = true
} else if (data.event === 'auth-incorrect') {
connection.authenticated = false
}
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-ok') resolveConnection()
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
@@ -82,17 +58,11 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
connection.authenticated = false
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
rejectConnection(
new Error(
`WebSocket closed before authentication for server ${serverId} (code: ${event.code})`,
),
)
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
@@ -107,14 +77,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
type: (event as Event).type,
})
rejectConnection(
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
}
} catch (error) {
rejectConnection(error)
reject(error)
}
})
}
@@ -1,8 +1,7 @@
<template>
<Admonition
v-if="installation"
:type="installation.status === 'failed' ? 'critical' : 'info'"
:dismissible="installation.status === 'failed'"
:type="contentError ? 'critical' : 'info'"
:dismissible="dismissible"
:progress="progressValue"
progress-color="blue"
:waiting="isWaiting"
@@ -11,8 +10,23 @@
<template #header>
{{ headerLabel }}
</template>
{{ installation.status === 'failed' ? errorLabel : descriptionLabel }}
<template v-if="installation.status === 'failed'" #top-right-actions>
<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>
<Button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
type="outlined"
@@ -30,17 +44,29 @@
<script setup lang="ts">
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import Admonition from '../base/Admonition.vue'
defineProps<{
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
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
@@ -51,182 +77,191 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const { installation } = injectModrinthServerContext()
const messages = defineMessages({
errorHeader: {
id: 'servers.installing-banner.error.header',
defaultMessage: 'Installation failed',
},
platformErrorHeader: {
id: 'servers.installing-banner.error.header.platform',
defaultMessage: 'Failed to install {loader} {loaderVersion} for Minecraft {gameVersion}',
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: "We're preparing your server",
},
platformWithoutVersionErrorHeader: {
id: 'servers.installing-banner.error.header.platform-without-version',
defaultMessage: 'Failed to install {loader} for Minecraft {gameVersion}',
invalidLoaderVersionError: {
id: 'servers.installing-banner.error.invalid-loader-version',
defaultMessage:
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
},
minecraftErrorHeader: {
id: 'servers.installing-banner.error.header.minecraft',
defaultMessage: 'Failed to install Minecraft {version}',
unsupportedLoaderVersionError: {
id: 'servers.installing-banner.error.unsupported-loader-version',
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
},
modpackErrorHeader: {
id: 'servers.installing-banner.error.header.modpack',
defaultMessage: 'Failed to install modpack',
internalPlatformError: {
id: 'servers.installing-banner.error.internal-platform',
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
},
localModpackErrorHeader: {
id: 'servers.installing-banner.error.header.local-modpack',
defaultMessage: 'Failed to install {filename}',
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.installing-platform',
defaultMessage: 'Installing {loader} for Minecraft {version}',
},
installingMinecraft: {
id: 'servers.installing-banner.installing-minecraft',
defaultMessage: 'Installing Minecraft {version}',
id: 'servers.installing-banner.phase.installing-platform',
defaultMessage: 'Installing platform...',
},
installingModpack: {
id: 'servers.installing-banner.installing-modpack',
defaultMessage: 'Installing modpack',
id: 'servers.installing-banner.phase.installing-modpack',
defaultMessage: 'Installing modpack...',
},
installingLocalModpack: {
id: 'servers.installing-banner.installing-local-modpack',
defaultMessage: 'Installing {filename}',
installingAddons: {
id: 'servers.installing-banner.phase.installing-addons',
defaultMessage: 'Installing addons...',
},
preparingDescription: {
id: 'servers.installing-banner.description.preparing',
defaultMessage: 'Preparing your server...',
tickerOrganizingFiles: {
id: 'servers.installing-banner.ticker.organizing-files',
defaultMessage: 'Organizing files...',
},
applyingDescription: {
id: 'servers.installing-banner.description.applying',
defaultMessage: 'Applying your installation changes...',
tickerDownloadingMods: {
id: 'servers.installing-banner.ticker.downloading-mods',
defaultMessage: 'Downloading mods...',
},
durationDescription: {
id: 'servers.installing-banner.description.duration',
defaultMessage: 'This installation may take several minutes...',
tickerConfiguringServer: {
id: 'servers.installing-banner.ticker.configuring-server',
defaultMessage: 'Configuring server...',
},
controlsDescription: {
id: 'servers.installing-banner.description.controls',
defaultMessage: 'Server controls will unlock when installation finishes.',
tickerSettingUpEnvironment: {
id: 'servers.installing-banner.ticker.setting-up-environment',
defaultMessage: 'Setting up environment...',
},
stillWorkingDescription: {
id: 'servers.installing-banner.description.still-working',
defaultMessage: 'Still working—your installation is in progress...',
tickerAddingJava: {
id: 'servers.installing-banner.ticker.adding-java',
defaultMessage: 'Adding Java...',
},
})
const headerLabel = computed(() => {
const current = installation.value
if (!current) return ''
const errorLabel = computed(() => {
const desc = props.contentError?.description?.toLowerCase()
const step = props.contentError?.step
switch (current.key.type) {
case 'platform': {
if (current.key.platform === 'vanilla') {
if (current.status === 'failed') {
return formatMessage(messages.minecraftErrorHeader, {
version: current.key.game_version,
})
}
return formatMessage(messages.installingMinecraft, {
version: current.key.game_version,
})
}
if (current.status === 'failed') {
if (!current.key.platform_version) {
return formatMessage(messages.platformWithoutVersionErrorHeader, {
loader: formatLoaderLabel(current.key.platform),
gameVersion: current.key.game_version,
})
}
return formatMessage(messages.platformErrorHeader, {
loader: formatLoaderLabel(current.key.platform),
loaderVersion: current.key.platform_version,
gameVersion: current.key.game_version,
})
}
return formatMessage(messages.installingPlatform, {
loader: formatLoaderLabel(current.key.platform),
version: current.key.game_version,
})
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)
}
case 'modrinth_modpack':
return formatMessage(
current.status === 'failed' ? messages.modpackErrorHeader : messages.installingModpack,
)
case 'local_modpack':
return formatMessage(
current.status === 'failed'
? messages.localModpackErrorHeader
: messages.installingLocalModpack,
{
filename: current.key.filename,
},
)
case 'unknown':
return formatMessage(
current.status === 'failed' ? messages.errorHeader : messages.preparingHeader,
)
}
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 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)
const phaseLabel = computed(() => {
switch (effectivePhase.value) {
case 'InstallingLoader':
return formatMessage(messages.installingPlatform)
case 'InstallingPack':
return formatMessage(messages.installingModpack)
case 'Addons':
return formatMessage(messages.installingAddons)
default:
return descriptionIndex.value % 2 === 1
? formatMessage(messages.controlsDescription)
: formatMessage(messages.stillWorkingDescription)
return formatMessage(commonMessages.installingLabel)
}
})
const errorLabel = computed(() => installation.value?.error ?? formatMessage(messages.unknownError))
const progressValue = computed(() => {
const current = installation.value
if (!current || current.status === 'failed') return undefined
return current.progress == null ? 0 : current.progress / 100
if (props.contentError) return undefined
return props.progress ? props.progress.percent / 100 : 0
})
const isWaiting = computed(() => {
const current = installation.value
if (!current || current.status === 'failed') return false
return current.progress == null || current.progress <= 0
if (props.contentError) return false
return !props.progress || props.progress.percent <= 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(() => {
if (installation.value?.status === 'pending' || installation.value?.status === 'installing') {
descriptionIndex.value += 1
}
}, 15_000)
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
}, 3000)
})
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,11 +131,6 @@ 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!,
@@ -164,15 +159,6 @@ 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!,
@@ -197,7 +183,6 @@ 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),
@@ -225,10 +210,6 @@ 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,12 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } 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 from '#ui/components/servers/InstallingBanner.vue'
import InstallingBanner, {
type ContentError,
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
@@ -17,8 +20,13 @@ import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition
import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
}>()
const emit = defineEmits<{
'installation-retry': []
'content-retry': []
}>()
const { formatMessage } = useVIntl()
@@ -50,18 +58,28 @@ const messages = defineMessages({
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const bannerCoversInstalling = computed(
() =>
ctx.server.value?.status === 'installing' ||
ctx.isSyncingContent.value ||
ctx.busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
),
)
function isBackupReason(id: string) {
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
}
function isInstallingReason(id: string) {
return id === 'servers.busy.installing'
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
}
const filteredBusyReasons = computed(() =>
ctx.busyReasons.value.filter((r) => {
if (isBackupReason(r.reason.id)) return false
if (isInstallingReason(r.reason.id)) return false
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
return true
}),
)
@@ -77,6 +95,17 @@ const filesBusyHeader = computed(() =>
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const uploadCancelling = ref(false)
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
)
watch(contentErrorKey, (key) => {
if (!key) {
dismissedContentErrorKey.value = null
}
})
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
const result: BackupAdmonitionEntry[] = []
@@ -142,7 +171,12 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
)
const showInstallingBanner = computed(() => {
return !!ctx.installation.value && ctx.installation.value.status !== 'complete'
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'
})
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
@@ -176,11 +210,10 @@ const stackItems = computed<ServerAdmonitionItem[]>(() => {
let sortIndex = 0
if (showInstallingBanner.value) {
const failed = ctx.installation.value?.status === 'failed'
out.push({
id: 'installing',
type: failed ? 'critical' : 'info',
dismissible: failed,
type: props.contentError ? 'critical' : 'info',
dismissible: !!props.contentError,
kind: 'installing',
priority: 0,
sortIndex: sortIndex++,
@@ -332,8 +365,8 @@ async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
if (!it.dismissible) continue
if (it.kind === 'installing') {
onInstallationDismiss()
if (it.kind === 'installing' && props.contentError) {
onContentErrorDismiss()
} else if (it.kind === 'fs-op' && it.op.id) {
const { op } = it
if (op.state === 'done' || op.state?.startsWith('fail')) {
@@ -352,9 +385,9 @@ function onFileOpDismiss(item: ServerAdmonitionItem) {
}
}
function onInstallationDismiss() {
if (ctx.installation.value) {
ctx.dismissInstallation(ctx.installation.value.id)
function onContentErrorDismiss() {
if (contentErrorKey.value) {
dismissedContentErrorKey.value = contentErrorKey.value
}
}
</script>
@@ -369,10 +402,14 @@ function onInstallationDismiss() {
<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="onInstallationDismiss"
@retry="emit('installation-retry')"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
<UploadAdmonition
v-else-if="item.kind === 'upload'"
@@ -13,12 +13,20 @@ export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, powerState, busyReasons } = injectModrinthServerContext()
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(() =>
busyReasons.value.some((reason) => reason.reason.id === 'servers.busy.installing'),
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 isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
-2
View File
@@ -14,9 +14,7 @@ export * from './scroll-indicator'
export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-context-runtime'
export * from './server-manage-core-runtime'
export * from './server-panel-sync'
export * from './server-permissions'
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
export * from './sticky-observer'
@@ -1,350 +0,0 @@
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { injectModrinthClient } from '../providers'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type RuntimeUnsubscriber = () => void
type RuntimeReadyWaiter = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
type ServerContextRuntime = {
client: AbstractModrinthClient
serverId: string
leases: number
socketLeases: number
syncLeases: number
releaseTimer: ReturnType<typeof setTimeout> | null
socketReleaseTimer: ReturnType<typeof setTimeout> | null
syncReleaseTimer: ReturnType<typeof setTimeout> | null
connectPromise: Promise<void> | null
socketUnsubscribers: RuntimeUnsubscriber[]
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
readyWaiters: Set<RuntimeReadyWaiter>
destroyed: boolean
}
export type ServerContextRuntimeLease = {
serverId: string
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
waitUntilReady: () => Promise<void>
release: () => void
}
type RetainServerContextRuntimeOptions = {
connect?: boolean
socket?: boolean
sync?: boolean
}
const runtimeReleaseDelay = 1000
const authoritativeReadinessTimeout = 30000
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
function getClientRuntimes(client: AbstractModrinthClient) {
let runtimes = runtimesByClient.get(client)
if (!runtimes) {
runtimes = new Map()
runtimesByClient.set(client, runtimes)
}
return runtimes
}
function isRuntimeReady(runtime: ServerContextRuntime) {
return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value
}
function resolveReadyWaiters(runtime: ServerContextRuntime) {
if (!isRuntimeReady(runtime)) return
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.resolve()
}
runtime.readyWaiters.clear()
}
function createServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
): ServerContextRuntime {
const runtime: ServerContextRuntime = {
client,
serverId,
leases: 0,
socketLeases: 0,
syncLeases: 0,
releaseTimer: null,
socketReleaseTimer: null,
syncReleaseTimer: null,
connectPromise: null,
socketUnsubscribers: [],
installProgressItems: ref([]),
isSocketAuthenticated: ref(false),
isSocketAuthIncorrect: ref(false),
hasAuthoritativeInstallProgress: ref(false),
readyWaiters: new Set(),
destroyed: false,
}
return runtime
}
function attachRuntimeSocketListeners(runtime: ServerContextRuntime) {
if (runtime.socketUnsubscribers.length > 0) return
runtime.socketUnsubscribers = [
runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => {
runtime.isSocketAuthenticated.value = true
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => {
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = true
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => {
runtime.installProgressItems.value = event.items
runtime.hasAuthoritativeInstallProgress.value = true
resolveReadyWaiters(runtime)
}),
]
}
function disconnectRuntimeSocket(runtime: ServerContextRuntime) {
for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe()
runtime.socketUnsubscribers = []
runtime.client.archon.sockets.disconnect(runtime.serverId)
runtime.connectPromise = null
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`))
}
runtime.readyWaiters.clear()
}
function disconnectRuntimeSync(runtime: ServerContextRuntime) {
runtime.client.archon.sync.disconnect(runtime.serverId)
}
async function ensureRuntimeConnections(
runtime: ServerContextRuntime,
options: RetainServerContextRuntimeOptions = {},
) {
if (runtime.destroyed) {
throw new Error(`Server context runtime for ${runtime.serverId} has been released`)
}
const shouldConnectSocket = options.socket !== false
const shouldConnectSync = options.sync !== false
const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId)
if (shouldConnectSocket && !socketStatus?.connected) {
attachRuntimeSocketListeners(runtime)
runtime.isSocketAuthenticated.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}
if (shouldConnectSync) {
void runtime.client.archon.sync
.safeConnectServer(runtime.serverId, { intent: 'all' })
.catch((error) => {
console.warn(
`[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`,
error,
)
})
}
if (shouldConnectSocket && !runtime.connectPromise) {
const connectPromise = runtime.client.archon.sockets
.safeConnect(runtime.serverId)
.then(() => {
runtime.isSocketAuthenticated.value = true
})
.finally(() => {
if (runtime.connectPromise === connectPromise) {
runtime.connectPromise = null
}
})
runtime.connectPromise = connectPromise
}
if (runtime.connectPromise) await runtime.connectPromise
}
async function waitUntilRuntimeReady(runtime: ServerContextRuntime) {
await ensureRuntimeConnections(runtime)
if (isRuntimeReady(runtime)) return
await new Promise<void>((resolve, reject) => {
const waiter: RuntimeReadyWaiter = {
resolve,
reject,
timeout: setTimeout(() => {
runtime.readyWaiters.delete(waiter)
reject(
new Error(
`Timed out waiting for authoritative install progress for server ${runtime.serverId}`,
),
)
}, authoritativeReadinessTimeout),
}
runtime.readyWaiters.add(waiter)
resolveReadyWaiters(runtime)
})
}
function destroyRuntime(runtime: ServerContextRuntime) {
if (runtime.destroyed || runtime.leases > 0) return
runtime.destroyed = true
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
disconnectRuntimeSocket(runtime)
disconnectRuntimeSync(runtime)
getClientRuntimes(runtime.client).delete(runtime.serverId)
}
export function retainServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
options: RetainServerContextRuntimeOptions = {},
): ServerContextRuntimeLease {
const runtimes = getClientRuntimes(client)
let runtime = runtimes.get(serverId)
if (!runtime) {
runtime = createServerContextRuntime(client, serverId)
runtimes.set(serverId, runtime)
}
if (runtime.releaseTimer) {
clearTimeout(runtime.releaseTimer)
runtime.releaseTimer = null
}
const retainSocket = options.socket !== false
const retainSync = options.sync !== false
if (retainSocket) {
if (runtime.socketReleaseTimer) {
clearTimeout(runtime.socketReleaseTimer)
runtime.socketReleaseTimer = null
}
attachRuntimeSocketListeners(runtime)
runtime.socketLeases += 1
}
if (retainSync) {
if (runtime.syncReleaseTimer) {
clearTimeout(runtime.syncReleaseTimer)
runtime.syncReleaseTimer = null
}
runtime.syncLeases += 1
}
runtime.leases += 1
if (options.connect !== false) {
void ensureRuntimeConnections(runtime, options).catch((error) => {
if (runtime && runtime.leases > 0) {
console.warn(
`[server-context-runtime] Failed to connect node socket for ${serverId}:`,
error,
)
}
})
}
let released = false
return {
serverId,
installProgressItems: runtime.installProgressItems,
isSocketAuthenticated: runtime.isSocketAuthenticated,
isSocketAuthIncorrect: runtime.isSocketAuthIncorrect,
hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress,
waitUntilReady: () => waitUntilRuntimeReady(runtime),
release: () => {
if (released) return
released = true
runtime.leases = Math.max(0, runtime.leases - 1)
if (retainSocket) {
runtime.socketLeases = Math.max(0, runtime.socketLeases - 1)
}
if (retainSync) {
runtime.syncLeases = Math.max(0, runtime.syncLeases - 1)
}
if (runtime.leases === 0) {
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
runtime.socketReleaseTimer = null
runtime.syncReleaseTimer = null
runtime.releaseTimer = setTimeout(() => {
runtime.releaseTimer = null
destroyRuntime(runtime)
}, runtimeReleaseDelay)
return
}
if (retainSocket && runtime.socketLeases === 0) {
runtime.socketReleaseTimer = setTimeout(() => {
runtime.socketReleaseTimer = null
if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime)
}, runtimeReleaseDelay)
}
if (retainSync && runtime.syncLeases === 0) {
runtime.syncReleaseTimer = setTimeout(() => {
runtime.syncReleaseTimer = null
if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime)
}, runtimeReleaseDelay)
}
},
}
}
export function useServerContextRuntime(serverId: ReadableRef<string | null>) {
const client = injectModrinthClient()
let lease: ServerContextRuntimeLease | null = null
const stop = watch(
() => serverId.value,
(nextServerId) => {
lease?.release()
lease = null
if (typeof window !== 'undefined' && nextServerId) {
lease = retainServerContextRuntime(client, nextServerId)
}
},
{ immediate: true },
)
onUnmounted(() => {
stop()
lease?.release()
lease = null
})
}
export async function waitForServerContextRuntimeReady(
client: AbstractModrinthClient,
serverId: string,
) {
const lease = retainServerContextRuntime(client, serverId)
try {
await lease.waitUntilReady()
} finally {
lease.release()
}
}
@@ -1,342 +0,0 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } 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>
content?: ReadableRef<Archon.Content.v1.Addons | null | undefined>
}
type ServerInstallationPlatform = Extract<ServerInstallationKey, { type: 'platform' }>['platform']
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'
}
function contentPlatform(modloader: string | null): ServerInstallationPlatform | null {
const platform = modloader === 'neo_forge' ? 'neoforge' : modloader
switch (platform) {
case 'forge':
case 'neoforge':
case 'fabric':
case 'quilt':
case 'paper':
case 'purpur':
case 'vanilla':
return platform
default:
return null
}
}
function contentInstallationKey(content: Archon.Content.v1.Addons): ServerInstallationKey {
if (content.installing === 'modpack' || (content.error && content.modpack)) {
const spec = content.modpack?.spec
if (spec?.platform === 'modrinth') {
return {
type: 'modrinth_modpack',
project_id: spec.project_id,
version_id: spec.version_id,
}
}
if (spec?.platform === 'local_file') {
return {
type: 'local_modpack',
filename: spec.filename,
}
}
return { type: 'unknown' }
}
const platform = contentPlatform(content.modloader)
if (platform && content.game_version) {
return {
type: 'platform',
platform,
platform_version: content.modloader_version ?? '',
game_version: content.game_version,
}
}
return { type: 'unknown' }
}
function addonFailureId(addon: Archon.Content.v1.Addon) {
return `addon:${addon.id}:${addon.filename}`
}
function addonFailureError(addon: Archon.Content.v1.Addon) {
return typeof addon.status === 'object' ? addon.status.failed.error : null
}
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 persistedAddonFailure = computed<ServerInstallationState | null>(() => {
const addon = options.content?.value?.addons?.find((addon) => {
const id = addonFailureId(addon)
return addonFailureError(addon) != null && !dismissedIds.value.has(id)
})
if (!addon) return null
return {
id: addonFailureId(addon),
key: { type: 'unknown' },
status: 'failed',
progress: null,
error: addonFailureError(addon),
source: 'server',
}
})
const persistedInstallation = computed<ServerInstallationState | null>(() => {
const content = options.content?.value
if (!content || (!content.installing && !content.error)) return null
const key = contentInstallationKey(content)
const id = installationKeyId(key)
if (dismissedIds.value.has(id)) return null
return {
id,
key,
status: content.error ? 'failed' : 'installing',
progress: null,
error: content.error?.message ?? null,
source: 'server',
}
})
watch(
() => options.content?.value,
(content) => {
if (!content) return
if (content.error) {
optimisticInstallation.value = null
return
}
if (!content.installing) return
const id = installationKeyId(contentInstallationKey(content))
if (dismissedIds.value.has(id)) {
dismissedIds.value = new Set([...dismissedIds.value].filter((item) => item !== id))
}
optimisticInstallation.value = null
},
)
watch(
() => options.content?.value?.addons,
(addons) => {
const currentFailureIds = new Set(
(addons ?? []).filter((addon) => addonFailureError(addon) != null).map(addonFailureId),
)
const nextDismissedIds = new Set(
[...dismissedIds.value].filter(
(id) => !id.startsWith('addon:') || currentFailureIds.has(id),
),
)
if (nextDismissedIds.size !== dismissedIds.value.size) {
dismissedIds.value = nextDismissedIds
}
},
)
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 (persistedInstallation.value) return persistedInstallation.value
if (persistedAddonFailure.value) return persistedAddonFailure.value
if (options.content?.value?.error) return null
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 optimistic = optimisticInstallation.value
const isPostOptimisticSnapshot =
optimistic !== null && snapshotRevision.value > optimistic.startRevision
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 (isPostOptimisticSnapshot) {
nextDismissedIds.delete(id)
}
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,
}
}
@@ -5,23 +5,19 @@ import {
type UploadState,
} from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
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 { useModrinthServersConsole } from './server-console'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from './server-context-runtime'
import { useServerInstallationTracker } from './server-installation-tracker'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
@@ -30,7 +26,7 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
content?: ReadableRef<Archon.Content.v1.Addons | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
@@ -100,25 +96,10 @@ 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 {
begin: beginInstallation,
cancelOptimistic: cancelOptimisticInstallation,
dismiss: dismissInstallation,
handleProgress: handleInstallProgress,
installation,
installProgressItems,
isBlocking: isInstallationBlocking,
reset: resetInstallation,
} = useServerInstallationTracker({
worldId: options.worldId,
server: options.server,
content: options.content,
})
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
@@ -126,7 +107,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (isInstallationBlocking.value) {
if (options.server.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
@@ -134,6 +115,14 @@ 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
})
@@ -276,6 +265,20 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
startUptimeTicker()
}
const handleAuthIncorrect = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = true
if (options.setDisconnectedOnAuthIncorrect) {
isConnected.value = false
}
}
const handleAuthOk = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = false
isConnected.value = true
}
const clearSocketListeners = () => {
for (const unsub of socketUnsubscribers.value) unsub()
socketUnsubscribers.value = []
@@ -285,8 +288,10 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
if (!targetServerId && !connectedSocketServerId.value) return
clearSocketListeners()
serverContextRuntimeLease?.release()
serverContextRuntimeLease = null
if (targetServerId) {
client.archon.sockets.disconnect(targetServerId)
}
stopUptimeTicker()
clearStaleStatsTimers()
@@ -296,7 +301,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
resetInstallation()
}
const connectSocket = async (
@@ -313,11 +317,14 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
disconnectSocket(connectedSocketServerId.value ?? undefined)
try {
const runtimeLease = retainServerContextRuntime(client, targetServerId, {
connect: false,
})
serverContextRuntimeLease = runtimeLease
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
connectedSocketServerId.value = targetServerId
isConnected.value = true
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
@@ -326,45 +333,15 @@ 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),
watch(
runtimeLease.installProgressItems,
(items) => {
if (shouldProcessEvent()) handleInstallProgress(items)
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthenticated,
(authenticated) => {
if (!shouldProcessEvent()) return
if (authenticated || options.setDisconnectedOnAuthIncorrect) {
isConnected.value = authenticated
}
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthIncorrect,
(authIncorrect) => {
if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect
},
{ immediate: true },
),
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
]
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
await runtimeLease.waitUntilReady()
isConnected.value = true
isWsAuthIncorrect.value = false
return true
} catch (error) {
console.error('[hosting/manage] Failed to connect server socket:', error)
disconnectSocket(targetServerId)
isConnected.value = false
return false
}
}
@@ -425,11 +402,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isServerRunning,
stats,
uptimeSeconds,
installProgressItems,
installation,
beginInstallation,
cancelOptimisticInstallation,
dismissInstallation,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
busyReasons,
fsAuth,
fsOps,
@@ -450,25 +423,20 @@ 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,
installProgressItems,
powerStateDetails,
ramData,
refreshFsAuth,
+12 -121
View File
@@ -5,16 +5,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
import { injectModrinthClient } from '#ui/providers'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from './server-context-runtime'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SyncUnsubscriber = () => void
type UseServerPanelSyncOptions = {
serverId: ReadableRef<string | null>
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
}
@@ -25,7 +20,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const queryClient = useQueryClient()
let activeServerId: string | null = null
let runtimeLease: ServerContextRuntimeLease | null = null
let unsubscribers: SyncUnsubscriber[] = []
let mounted = false
let actionLogInvalidateTimer: ReturnType<typeof setTimeout> | null = null
@@ -33,8 +27,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const legacyServerDetailKey = (serverId: string) => ['servers', 'detail', serverId] as const
const serverV1DetailKey = (serverId: string) => ['servers', 'v1', 'detail', serverId] as const
const contentListKey = (serverId: string) => ['content', 'list', 'v1', serverId] as const
const modpackContentListKey = (serverId: string) =>
['content', 'list', 'v1', serverId, 'modpack'] as const
const actionLogBaseKey = (serverId: string) =>
['servers', 'action-log', 'v1', 'infinite', serverId] as const
@@ -51,9 +43,12 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
unsubscribers = [
client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
]
runtimeLease = retainServerContextRuntime(client, targetServerId, {
socket: false,
sync: true,
void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
console.warn(
`[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
error,
)
})
}
@@ -66,9 +61,10 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
for (const unsubscribe of unsubscribers) unsubscribe()
unsubscribers = []
runtimeLease?.release()
runtimeLease = null
activeServerId = null
if (activeServerId) {
client.archon.sync.disconnect(activeServerId)
activeServerId = null
}
}
function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
@@ -115,9 +111,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
case 'world.content.base.update':
handleWorldContentBaseUpdate(serverId, event)
break
case 'world.content.update':
handleWorldContentUpdate(serverId, event)
break
}
}
@@ -223,106 +216,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
function handleWorldContentUpdate(
serverId: string,
event: Archon.Sync.v1.WorldContentUpdateEvent,
) {
if (event.world_id !== options.worldId.value) {
void invalidateContentAndServerDetails(serverId)
return
}
const content = worldContentUpdateToAddons(event)
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), {
...content,
addons: content.addons?.filter((addon) => !addon.from_modpack) ?? null,
})
queryClient.setQueryData<Archon.Content.v1.Addons>(modpackContentListKey(serverId), {
...content,
addons: content.addons?.filter((addon) => addon.from_modpack) ?? null,
})
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
function worldContentUpdateToAddons(
event: Archon.Sync.v1.WorldContentUpdateEvent,
): Archon.Content.v1.Addons {
return {
modloader: event.platform_data?.platform ?? null,
modloader_version: event.platform_data?.platform_version ?? null,
game_version: event.platform_data?.game_version ?? null,
modpack: worldContentModpackToModpackFields(event.linked_modpack),
installing: event.installing,
error: event.error,
addons: event.content.map(worldContentItemToAddon),
}
}
function worldContentModpackToModpackFields(
modpack: Archon.Sync.v1.WorldContentModpack | null,
): Archon.Content.v1.ModpackFields | null {
if (!modpack || modpack.spec === 'CurseForge') return null
const spec: Archon.Content.v1.ModpackSpec =
'Modrinth' in modpack.spec
? {
platform: 'modrinth',
project_id: modpack.spec.Modrinth.project_id,
version_id: modpack.spec.Modrinth.version_id,
}
: {
platform: 'local_file',
filename: modpack.spec.LocalMrPackFile.path,
name: modpack.spec.LocalMrPackFile.name,
description: modpack.spec.LocalMrPackFile.description,
}
return {
spec,
has_update: modpack.has_update,
title: modpack.title,
description: modpack.description,
icon_url: modpack.icon_url,
owner: modpack.owner,
version_number: modpack.version_number,
date_published: modpack.date_published,
downloads: modpack.downloads,
followers: modpack.followers,
}
}
function worldContentItemToAddon(item: Archon.Sync.v1.WorldContentItem): Archon.Content.v1.Addon {
return {
id: item.version?.id ?? item.version_id ?? item.file_sha1 ?? item.filename,
filename: item.filename,
filesize: item.filesize ?? 0,
btime: item.btime,
disabled: item.filename.endsWith('.disabled'),
kind: parentDirectoryToAddonKind(item.parent_directory),
from_modpack: item.from_modpack,
status: item.status,
pack_client_retained: item.pack_client_retained,
pack_client_depends: item.pack_client_depends,
has_update: item.has_update,
name: item.name,
project_id: item.project_id,
version: item.version,
owner: item.owner,
icon_url: item.icon_url,
}
}
function parentDirectoryToAddonKind(parentDirectory: string): Archon.Content.v1.AddonKind {
switch (parentDirectory) {
case 'plugins':
return 'plugin'
case 'datapacks':
return 'datapack'
default:
return 'mod'
}
}
function handleBackupEvent(serverId: string) {
void queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] })
void invalidateServerDetails(serverId)
@@ -403,9 +296,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
return {
...current,
...incoming,
status: incoming.status ?? current.status,
filesize: incoming.filesize || current.filesize,
btime: incoming.btime ?? current.btime,
name: incoming.name ?? current.name,
owner: incoming.owner ?? current.owner,
icon_url: incoming.icon_url ?? current.icon_url,
@@ -427,7 +318,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
onMounted(() => {
mounted = true
if (options.serverId.value) connect(options.serverId.value)
connect(options.serverId.value)
})
watch(
@@ -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>
+27
View File
@@ -3659,9 +3659,36 @@
"servers.grant-access-modal.target.searching": {
"defaultMessage": "Vyhledávání..."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Modrinth Hosting tuto verzi Minecraftu nebo loaderu zatím nepodporuje."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instalování addonů..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instalování modpacku..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instalování platformy..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Připravujeme váš server"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Přidávání Javy..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Konfigurování serveru..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Stahování módů..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizování souborů..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Nastavování prostředí..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Už máte server?"
},
+3
View File
@@ -1961,6 +1961,9 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installering fejlede"
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Konfigurer server..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Har du allerede en server?"
},
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "Server wird installiert"
},
"servers.busy.syncing-content": {
"defaultMessage": "Inhalt wird synchronisiert"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Auch eine Freundschaftsanfrage senden"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installation fehlgeschlagen"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Ein interner Fehler ist beim Installieren der Platform aufgetreten. Bitte versuch es später erneut."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Der angegebene Loader oder Minecraft Version konnte nicht installiert werden. Sie ist womöglich ungültig oder nicht unterstützt."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Das Modpack konnte nicht installiert werden. Es könnte womöglich beschädigt oder inkompatibel sein."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Diese Modpack-Version enthält keine hrunterladbare Datei. Es wurde womöglich inkorrekt erstellt."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Ein unerwarteter Fehler ist während der Installation aufgetreten."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Diese Version von Minecraft oder des Loaders werden aktuell noch nicht von Modrinth Hosting unterstützt."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Installiere Addons..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Installiere Modpack..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Installiere Platform..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Wir bereiten deinen Server vor"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Füge Java hinzu..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Konfiguriere Server..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Lade Mods herunter..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organisiere Dateien..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Richte die Umgebung ein..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Du hast bereits einen Server?"
},
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "Server wird installiert"
},
"servers.busy.syncing-content": {
"defaultMessage": "Inhalt wird synchronisiert"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Auch eine Freundschaftsanfrage senden"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installation fehlgeschlagen"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Während der Installation der Plattform ist ein interner Fehler aufgetreten. Bitte versuche es erneut."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Die angegebene Loader- oder Minecraft-Version konnte nicht installiert werden. Sie ist möglicherweise ungültig oder wird nicht unterstützt."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Das Modpack konnte nicht installiert werden. Es ist eventuell beschädigt oder nicht kompatibel."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Diese Modpack-Version enthält keine herunterladbare Datei. Es wurde eventuell falsch gepackt."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Während der Installation ist ein unerwarteter Fehler aufgetreten."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Diese Version von Minecraft oder des Loaders wird aktuell noch nicht von Modrinth Hosting unterstützt."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Addons werden installiert..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Modpack wird installiert..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Plattform wird installiert..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Wir bereiten deinen Server vor"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Java wird hinzugefügt..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Server wird konfiguriert..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Mods werden heruntergeladen..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Dateien werden organisiert..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Umgebung wird eingerichtet..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Du hast bereits einen Server?"
},
+35 -35
View File
@@ -4865,6 +4865,9 @@
"servers.busy.installing": {
"defaultMessage": "Server is installing"
},
"servers.busy.syncing-content": {
"defaultMessage": "Content sync in progress"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Also send a friend request"
},
@@ -4916,56 +4919,53 @@
"servers.grant-access-modal.target.searching": {
"defaultMessage": "Searching..."
},
"servers.installing-banner.description.applying": {
"defaultMessage": "Applying your installation changes..."
},
"servers.installing-banner.description.controls": {
"defaultMessage": "Server controls will unlock when installation finishes."
},
"servers.installing-banner.description.duration": {
"defaultMessage": "This installation may take several minutes..."
},
"servers.installing-banner.description.preparing": {
"defaultMessage": "Preparing your server..."
},
"servers.installing-banner.description.still-working": {
"defaultMessage": "Still working—your installation is in progress..."
},
"servers.installing-banner.error.header": {
"defaultMessage": "Installation failed"
},
"servers.installing-banner.error.header.local-modpack": {
"defaultMessage": "Failed to install {filename}"
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "An internal error occurred while installing the platform. Please try again."
},
"servers.installing-banner.error.header.minecraft": {
"defaultMessage": "Failed to install Minecraft {version}"
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "The specified loader or Minecraft version could not be installed. It may be invalid or unsupported."
},
"servers.installing-banner.error.header.modpack": {
"defaultMessage": "Failed to install modpack"
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "The modpack could not be installed. It may be corrupted or incompatible."
},
"servers.installing-banner.error.header.platform": {
"defaultMessage": "Failed to install {loader} {loaderVersion} for Minecraft {gameVersion}"
},
"servers.installing-banner.error.header.platform-without-version": {
"defaultMessage": "Failed to install {loader} for Minecraft {gameVersion}"
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "This modpack version does not include a downloadable file. It may have been packaged incorrectly."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "An unexpected error occurred during installation."
},
"servers.installing-banner.installing-local-modpack": {
"defaultMessage": "Installing {filename}"
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "This version of Minecraft or loader is not yet supported by Modrinth Hosting."
},
"servers.installing-banner.installing-minecraft": {
"defaultMessage": "Installing Minecraft {version}"
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Installing addons..."
},
"servers.installing-banner.installing-modpack": {
"defaultMessage": "Installing modpack"
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Installing modpack..."
},
"servers.installing-banner.installing-platform": {
"defaultMessage": "Installing {loader} for Minecraft {version}"
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Installing platform..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Preparing your server"
"defaultMessage": "We're preparing your server"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Adding Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configuring server..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Downloading mods..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizing files..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Setting up environment..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Already have a server?"
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "El servidor se está instalando"
},
"servers.busy.syncing-content": {
"defaultMessage": "Sincronización de contenido en curso"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Mandar solicitud de amistad también"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Fallo en la instalación"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Se ha producido un error interno durante la instalación de la plataforma. Por favor, inténtalo de nuevo."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "No se pudo instalar el loader o la versión de Minecraft especificados. Es posible que no sean válidos o que no sean compatibles."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "No se pudo instalar el modpack. Es posible que esté dañado o que no sea compatible."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Esta versión del modpack no incluye ningún archivo descargable. Es posible que se haya empaquetado incorrectamente."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Se produjo un error inesperado durante la instalación."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Esta versión de Minecraft o del loader aún no es compatible con Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instalando addons..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instalando modpack..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instalando plataforma..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Estamos preparando tu servidor"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Añadiendo java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configurando servidor..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Descargando mods..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizando archivos..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Preparando entorno..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "¿Ya tienes un servidor?"
},
+42
View File
@@ -4556,6 +4556,9 @@
"servers.busy.installing": {
"defaultMessage": "El servidor se está instalando"
},
"servers.busy.syncing-content": {
"defaultMessage": "Sincronización de contenido en progreso"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Enviar también una solicitud de amistad"
},
@@ -4610,12 +4613,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Instalación fallida"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Se ha producido un error interno durante la instalación de la plataforma. Por favor, inténtalo de nuevo."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "No se pudo instalar el loader o la versión de Minecraft especificados. Es posible que no sean válidos o que no sean compatibles."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "No se pudo instalar el modpack. Es posible que esté dañado o que no sea compatible."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Esta versión del modpack no incluye ningún archivo descargable. Es posible que se haya empaquetado incorrectamente."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Se produjo un error inesperado durante la instalación."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Esta versión de Minecraft o del loader aún no es compatible con Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instalando addons..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instalando modpack..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instalando plataforma..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Estamos preparando tu servidor"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Añadiendo Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configurando servidor..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Descargando mods..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizando archivos..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Preparando entorno..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "¿Ya tienes un servidor?"
},
+42
View File
@@ -4553,6 +4553,9 @@
"servers.busy.installing": {
"defaultMessage": "Le serveur est en cours d'installation"
},
"servers.busy.syncing-content": {
"defaultMessage": "Synchronisation du contenu en cours"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Envoyer également une demande d'ami"
},
@@ -4607,12 +4610,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Impossible d'installer"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Une erreur interne est survenue lors de l'installation de la platforme. Veuillez réessayer plus tard."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Le loader ou la version Minecraft spécifiée n'a pas pu être installée. Elle est peut-être invalide ou non prise en charge."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Le modpack n'a pas pu être installé. Il est peut-être corrompu ou incompatible."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Cette version de modpack ne contient pas de fichier téléchargeable. Il a peut-être été empaqueté incorrectement."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Une erreur inattendue est survenue lors de l'installation."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Cette version de Minecraft ou ce loader n'est peut-être pas encore bien pris en charge par Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Installation des add-ons..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Installation du modpack..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Installation de la platforme..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Nous préparons votre serveur"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Ajout de Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configuration du serveur..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Téléchargement de mods..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organisation des fichiers..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Mise en place de l'environnement..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Vous avez déjà un serveur ?"
},
+30
View File
@@ -3893,9 +3893,39 @@
"servers.installing-banner.error.header": {
"defaultMessage": "A telepítés nem sikerült"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Egy internal hiba történt a telepítés közben. Kérlek probáld újra."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Ez a modcsomag-verzió nem tartalmaz letölthető fájlt. Lehet, hogy hibásan lett összeállítva."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Kiegészítők telepítése..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "A modcsomag telepítése..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "A platform telepítése..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Előkészítjük a szervered"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Java hozzáadása..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "A szerver beállítása..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Modok letöltése..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Fájlok rendezése..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Környezet beállítása..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Már van egy szervered?"
},
+42
View File
@@ -4517,6 +4517,9 @@
"servers.busy.installing": {
"defaultMessage": "Installazione del server in corso"
},
"servers.busy.syncing-content": {
"defaultMessage": "Sincronizzazione dei contenuti in corso"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Invia anche una richiesta di amicizia"
},
@@ -4571,12 +4574,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installazione fallita"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Si è verificato un errore durante l'installazione della piattaforma. Riprova più tardi."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Questa versione di Minecraft o del loader non è potuta essere installata. Potrebbe essere non valida o non supportata."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Questo pacchetto non è potuto essere installato. Potrebbe essere corrotto o incompatibile."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Questa versione del pacchetto non include un file scaricabile. Potrebbe essere stata malformata."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Si è verificato un errore durante l'installazione."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Questa versione di Minecraft o del loader non è ancora supportata da Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Installando gli addon..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Installando il pacchetto..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Installando la piattaforma..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Stiamo preparando il tuo server"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Aggiungendo Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configurando il server..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Scaricando le mod..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizzando i file..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Configurando l'ambiente..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Hai già un server?"
},
+42
View File
@@ -4403,6 +4403,9 @@
"servers.busy.installing": {
"defaultMessage": "サーバーがインストール中です"
},
"servers.busy.syncing-content": {
"defaultMessage": "コンテンツの同期処理中です"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "フレンドのリクエストも送信する"
},
@@ -4457,12 +4460,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "インストールが失敗しました"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "プラットフォームのインストール中、内部エラーが発生しました。もう一度お試しください。"
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "特定のModローダーMinecraftのバージョンをインストールできませんでした。いずれかが非対応または無効である可能性があります。"
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "このModパックをインストールすることができませんでした。Modが非対応または破損している可能性があります。"
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "このModパックのバージョンには、ダウンロード可能なファイルが含まれていません。パッケージ化が正しく行われていない可能性があります。"
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "インストール中に予期せぬエラーが発生しました。"
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "このMinecraftのバージョンまたはModローダーはModrinthホスティングでまだ対応されていません。"
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "アドオンをインストールしています…"
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Modパックをインストールしています…"
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "プラットフォームをインストールしています…"
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "あなたのサーバーを準備しています"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Javaを追加しています…"
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "サーバーの設定をしています…"
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Modをダウンロードしています…"
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "ファイルを整理しています…"
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "環境を整えています…"
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "既にサーバーをお持ちですか?"
},
+42
View File
@@ -4544,6 +4544,9 @@
"servers.busy.installing": {
"defaultMessage": "서버 설치 중"
},
"servers.busy.syncing-content": {
"defaultMessage": "콘텐츠 동기화 진행 중"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "친구 요청도 보내기"
},
@@ -4598,12 +4601,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "설치 실패"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "플랫폼 설치 중 내부 오류가 발생했습니다. 다시 시도해 주세요."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "지정한 로더 또는 마인크래프트 버전을 설치할 수 없습니다. 유효하지 않거나 지원되지 않는 것일 수 있습니다."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "모드팩을 설치할 수 없습니다. 파일이 손상되었거나 호환되지 않을 수 있습니다."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "이 모드팩 버전은 다운로드 가능한 파일을 포함되어 있지 않습니다. 패키징이 잘못되었을 수 있습니다."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "설치 중 알 수 없는 오류가 발생했습니다."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "이 미인크래프트 또는 로더 버전은 아직 Modrinth Hosting에서 지원되지 않습니다."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "애드온 설치 중..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "모드팩 설치 중..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "플랫폼 설치 중..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "서버를 준비하고 있습니다"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Java 추가 중..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "서버 구성 중..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "모드 다운로드 중..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "파일 준비 중..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "환경 설정 중..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "서버가 이미 있으신가요?"
},
+39
View File
@@ -3848,6 +3848,9 @@
"servers.busy.installing": {
"defaultMessage": "Pelayan sedang dipasang"
},
"servers.busy.syncing-content": {
"defaultMessage": "Penyegerakan kandungan sedang dijalankan"
},
"servers.grant-access-modal.cancel": {
"defaultMessage": "Batal"
},
@@ -3878,9 +3881,45 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Pemasangan gagal"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Sebuah ralat dalaman telah berlaku semasa memasang platform. Sila cuba lagi kemudian."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Pemuat atau versi Minecraft yang dinyatakan tidak dapat dipasang. Ia mungkin tidak sah atau tidak disokong."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Versi pek mod ini tidak menyertakan fail yang boleh dimuat turun. Ia mungkin telah dibungkus secara salah."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Versi Minecraft atau pemuat ini belum disokong oleh Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Sedang memasang tambahan..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Sedang memasang pek mod..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Sedang memasang platform..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Kami sedang menyediakan pelayan anda"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Sedang menambah Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Sedang mengkonfigurasikan pelayan..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Sedang memuat turun mod..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Sedang menyusun fail..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Sedang menyediakan persekitaran..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Sudah mempunyai pelayan?"
},
+42
View File
@@ -4550,6 +4550,9 @@
"servers.busy.installing": {
"defaultMessage": "De server wordt geïnstalleerd"
},
"servers.busy.syncing-content": {
"defaultMessage": "Inhoud wordt gesynchroniseerd"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Ook een vriendschapsverzoek sturen"
},
@@ -4604,12 +4607,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installeren is mislukt"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Er is een interne fout opgetreden tijdens het installeren van het platform. Probeer het nog eens."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "De opgegeven loader of Minecraft-versie kon niet worden geïnstalleerd. Deze is mogelijk ongeldig of wordt niet ondersteund."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Het modpack kon niet worden geïnstalleerd. Het is mogelijk beschadigd of niet compatibel."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Deze versie van het modpack bevat geen downloadbaar bestand. Mogelijk is het pakket niet correct samengesteld."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Tijdens de installatie is er een onverwachte fout opgetreden."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Deze versie van Minecraft of loader wordt nog niet ondersteund door Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Add-ons aan het installeren..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Modpack aan het installeren..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Platform aan het installeren..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "We zijn je server aan het klaarmaken"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Java toevoegen..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Server wordt geconfigureerd..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Mods worden gedownload..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Bestanden worden geordend..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Omgeving wordt opgezet..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Heb je al een server?"
},
+42
View File
@@ -4526,6 +4526,9 @@
"servers.busy.installing": {
"defaultMessage": "Serwer jest instalowany"
},
"servers.busy.syncing-content": {
"defaultMessage": "Synchronizacja zawartości w toku"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Wyślij także zaproszenie do znajomych"
},
@@ -4580,12 +4583,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Instalacja nie powiodła się"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Wystąpił wewnętrzny błąd podczas instalowania platformy. Spróbuj ponownie później."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Nie udało się zainstalować podanego loadera lub podanej wersji Minecraft. Mogą być niepoprawne lub niewspierane."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Nie udało się zainstalować paczki modów. Może być zepsuta lub niekompatybilna."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Ta wersja paczki modów nie zawiera pliku do pobrania. Możliwe, że została niepoprawnie zapakowana."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Wystąpił nieoczekiwany błąd podczas instalacji."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Ta wersja Minecraft lub loader nie są jeszcze wspierane przez Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instalowanie dodatków..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instalowanie paczki modów..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instalowanie platformy..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Przygotowujemy Twój serwer"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Dodawanie Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Ustawianie serwera..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Pobieranie modów..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizowanie plików..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Ustawianie środowiska..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Posiadasz już serwer?"
},
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "O servidor está sendo instalado"
},
"servers.busy.syncing-content": {
"defaultMessage": "Sincronização de conteúdo em andamento"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Envie também um pedido de amizade"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "A instalação falhou"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Ocorreu um erro interno durante a instalação da plataforma. Tente novamente."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Não foi possível instalar o carregador ou a versão do Minecraft especificada. Ela pode ser inválida ou não suportada."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "O pacote de mods não pôde ser instalado. Ele pode estar corrompido ou ser incompatível."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Esta versão do pacote de mods não inclui um arquivo para download. Ela pode ter sido empacotada incorretamente."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Um erro inesperado ocorreu durante a instalação"
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Esta versão do Minecraft ou do carregador ainda não é suportada pelo Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instalando complementos..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instalando pacote de mods..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instalando plataforma"
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Nós estamos preparando seu servidor"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Adicionando Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Configurando servidor..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Baixando mods..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organizando arquivos..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Configurando ambiente..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Já possui um servidor?"
},
+42
View File
@@ -4472,6 +4472,9 @@
"servers.busy.installing": {
"defaultMessage": "Сервер устанавливается"
},
"servers.busy.syncing-content": {
"defaultMessage": "Синхронизация контента в процессе"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Также отправить запрос в друзья"
},
@@ -4526,12 +4529,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Установка не удалась"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Во время установки платформы произошла внутренняя ошибка. Попробуйте ещё раз."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Не удалось установить указанную версию загрузчика или Minecraft. Возможно, она недействительна или не поддерживается."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Не удалось установить сборку модов. Возможно, она повреждена или несовместима."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Эта версия сборки не содержит загрузочного файла. Возможно, она была собрана неправильно."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Во время установки произошла непредвиденная ошибка."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Эта версия Minecraft или загрузчика пока не поддерживается Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Установка дополнений..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Установка сборки..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Установка платформы..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Подготовка сервера"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Добавление Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Настройка сервера..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Скачивание модов..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Упорядочивание файлов..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Настройка среды..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Уже есть сервер?"
},
+42
View File
@@ -4328,6 +4328,9 @@
"servers.busy.installing": {
"defaultMessage": "Server se instalira"
},
"servers.busy.syncing-content": {
"defaultMessage": "Sinhronizacija sadržaja u toku"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Pošalji i zahtev za prijateljstvo"
},
@@ -4382,12 +4385,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Neuspešna instalacija"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Došlo je do interne greške prilikom instaliranja platforme. Molimo te, pokušaj ponovo."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Navedeni učitavač ili verzija Minecrafta se ne mogu instalirati. Moguće je da su nevažeći ili nepodržani."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Modpak nije mogao biti instaliran. Možda je oštećen ili nekompatibilan."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Ova verzija modpaka ne uključuje datoteku za preuzimanje. Moguće je da je pogrešno upakovana."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Došlo je do neočekivane greške tokom instalacije."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Ova verzija Minecrafta ili učitavača još uvek nije podržana od strane Modrinth Hosting-a."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Instaliranje addona..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Instaliranje modpacka..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Instaliranje platforme..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Pripremamo tvoj server"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Dodavanje Jave..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Konfigurisanje servera..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Instaliranje modova..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organiziranje datoteka..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Podešavanje okruženja..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Već imaš server?"
},
+30
View File
@@ -4145,12 +4145,42 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Installering misslyckades"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Ett internt fel inträffade när plattformen installerades. Vänligen försök igen."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Den angivna loader eller Minecraft-versionen kunde inte installeras. Den kan vara ogiltig eller stöds inte."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Modpaketet kunde inte installeras. Det kanske är korrumperad eller inkompatibel."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Ett oväntat fel inträffade under installeringen."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Denna version av Minecraft eller loader stöds ännu inte av Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Installerar tillägg..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Vi förbereder din server"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Lägger till Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Konfigurerar server..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Laddar ner moddar..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Organiserar filer..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Ställer in miljö..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Har du redan en server?"
},
+42
View File
@@ -4379,6 +4379,9 @@
"servers.busy.installing": {
"defaultMessage": "Sunucu kuruluyor"
},
"servers.busy.syncing-content": {
"defaultMessage": "İçerik eşlemesi sürüyor"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Ayrıca bir arkadaş isteği gönderin"
},
@@ -4433,12 +4436,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Kurulum başarısız oldu"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Platform yüklenirken dahili bir hata oluştu. Lütfen tekrar deneyin."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Belirtilen yükleyici veya Minecraft sürümü kurulamazdı. Belki de geçersiz veya desteklenmeyen bir sürüm olabilir."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Mod paketi yüklenemedi. Bozuk veya uyumsuz olabilir."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Bu mod paketi sürümü indirilebilir bir dosya içermez. Yanlış paketlenmiş olabilir."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Kurulum sırasında beklenmeyen bir hata oluştu."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Minecraft veya yükleyicinin bu sürümü henüz Modrinth Hosting tarafından desteklenmemektedir."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Eklentiler yükleniyor..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Mod paketi indiriliyor..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Platform yükleniyor..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Sunucunuzu hazırlıyoruz"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Java ekleniyor..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Sunucu yapılandırılıyor..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Modlar indiriliyor..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Dosyalar düzenleniyor..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Ortam hazırlanıyor..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Zaten sunucunuz var mı?"
},
+42
View File
@@ -4514,6 +4514,9 @@
"servers.busy.installing": {
"defaultMessage": "Сервер установлюється"
},
"servers.busy.syncing-content": {
"defaultMessage": "Триває синхронізація вмісту"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Також відправити запит у друзі"
},
@@ -4568,12 +4571,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "Не вдалося встановити"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Під час установлення платформи сталася внутрішня помилка. Спробуйте ще раз."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Не вдалося встановити вказаний завантажувач або версію Minecraft. Він може бути недійсним або не підтримуватися."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Збірку не вдалося встановити. Вона може бути пошкоджена або несумісна."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Ця версія збірки не містить завантажуваних файлів. Можливо, вона була неправильно впакований."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Під час встановлення сталася неочікувана помилка."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Ця версія Minecraft або завантажувача ще не підтримується Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Установлення доповнень…"
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Установлення збірки…"
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Установлення платформи…"
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Ми підготовлюємо ваш сервер"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Додання Java…"
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Налаштування сервера…"
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Завантаження модів…"
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Організування файлів…"
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Налаштування середовища…"
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Уже маєте сервер?"
},
+42
View File
@@ -3881,15 +3881,57 @@
"servers.busy.installing": {
"defaultMessage": "Đang cài máy chủ"
},
"servers.busy.syncing-content": {
"defaultMessage": "Đang đồng bộ nội dung"
},
"servers.installing-banner.error.header": {
"defaultMessage": "Tải thất bại"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "Đã xảy ra lỗi nội bộ trong quá trình cài đặt nền tảng. Thử lại."
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "Không thể tải loader hoặc phiên bản Minecraft đã chọn. Nó có thể không phù hợp hoặc không được hỗ trợ."
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "Modpack này không thể tải được. Nó có thể bị hỏng hoặc không tương thích."
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "Phiên bản modpack này không chứa tệp có thể tải xuống. Nó có thể không được làm đúng cách."
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "Có lỗi lạ xảy ra trong khi tải."
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Phiên bản minecraft hoặc loader hiện không được hỗ trợ bởi Modrinth Hosting."
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "Đang cài addon..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "Đang cài modpack..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "Đang cài platform..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "Chúng tôi đang sửa máy chủ của bạn"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "Đang thêm Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "Đang chỉnh sửa máy chủ..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "Đang tải mod..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "Đang sắp sếp tệp..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "Đang thiết lập môi trường..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "Đã có máy chủ rồi?"
},
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "正在安装服务器"
},
"servers.busy.syncing-content": {
"defaultMessage": "正在同步內容"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "同时发送好友邀请"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "安装失败"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "安装平台时发生内部错误,请重试。"
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "指定的加载器或 Minecraft 版本无法安装。它可能无效或不受支持。"
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "整合包无法安装。它可能已损坏或不兼容。"
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "这个整合包版本不包含可下载的文件,可能是打包方式有问题。"
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "安装过程中发生意外错误。"
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Modrinth Hosting 目前尚不支持此版本的 Minecraft 或加载器。"
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "正在安装组件……"
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "正在安装整合包……"
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "正在安装平台……"
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "我们正在准备你的服务器"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "正在添加 Java……"
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "正在配置服务器……"
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "正在下载模组……"
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "正在整理文件……"
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "正在设置运行环境……"
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "已经有服务器了吗?"
},
+42
View File
@@ -4559,6 +4559,9 @@
"servers.busy.installing": {
"defaultMessage": "正在安裝伺服器"
},
"servers.busy.syncing-content": {
"defaultMessage": "正在同步內容"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "同時傳送好友邀請"
},
@@ -4613,12 +4616,51 @@
"servers.installing-banner.error.header": {
"defaultMessage": "無法安裝"
},
"servers.installing-banner.error.internal-platform": {
"defaultMessage": "安裝平臺時發生內部錯誤,請再試一次。"
},
"servers.installing-banner.error.invalid-loader-version": {
"defaultMessage": "無法安裝指定的載入器或 Minecraft 版本。該載入器或版本可能無效或不受支援。"
},
"servers.installing-banner.error.modpack-install-failed": {
"defaultMessage": "無法安裝模組包。該模組包可能已損毀或不相容。"
},
"servers.installing-banner.error.no-primary-file": {
"defaultMessage": "這個模組包版本不包含可下載檔案。可能是打包時出了問題。"
},
"servers.installing-banner.error.unknown": {
"defaultMessage": "安裝時發生非預期的錯誤。"
},
"servers.installing-banner.error.unsupported-loader-version": {
"defaultMessage": "Modrinth Hosting 尚不支援這個 Minecraft 版本或載入器。"
},
"servers.installing-banner.phase.installing-addons": {
"defaultMessage": "正在安裝額外內容..."
},
"servers.installing-banner.phase.installing-modpack": {
"defaultMessage": "正在安裝模組包..."
},
"servers.installing-banner.phase.installing-platform": {
"defaultMessage": "正在安裝平臺..."
},
"servers.installing-banner.preparing.header": {
"defaultMessage": "我們正在為你準備伺服器"
},
"servers.installing-banner.ticker.adding-java": {
"defaultMessage": "正在新增 Java..."
},
"servers.installing-banner.ticker.configuring-server": {
"defaultMessage": "正在設定伺服器..."
},
"servers.installing-banner.ticker.downloading-mods": {
"defaultMessage": "正在下載模組..."
},
"servers.installing-banner.ticker.organizing-files": {
"defaultMessage": "正在整理檔案..."
},
"servers.installing-banner.ticker.setting-up-environment": {
"defaultMessage": "正在設定環境..."
},
"servers.list-empty.already-have-server-label": {
"defaultMessage": "已經有伺服器了嗎?"
},
+3 -9
View File
@@ -2,10 +2,6 @@ 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 '.'
@@ -53,11 +49,9 @@ export interface ModrinthServerContext {
readonly isServerRunning: ComputedRef<boolean>
readonly stats: Ref<ServerStats>
readonly uptimeSeconds: Ref<number>
readonly installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
readonly installation: ComputedRef<ServerInstallationState | null>
beginInstallation: (key: ServerInstallationKey) => void
cancelOptimisticInstallation: () => void
dismissInstallation: (id: string) => void
// Content sync state
readonly isSyncingContent: Ref<boolean>
// Busy state — when non-empty, all write operations should be disabled
readonly busyReasons: ComputedRef<BusyReason[]>
@@ -71,11 +71,7 @@ const meta = {
isServerRunning: computed(() => true),
stats,
uptimeSeconds: ref(0),
installProgressItems: ref<Archon.Websocket.v0.InstallProgressItem[]>([]),
installation: computed(() => null),
beginInstallation: () => {},
cancelOptimisticInstallation: () => {},
dismissInstallation: () => {},
isSyncingContent: ref(false),
busyReasons: computed(() => []),
fsAuth: ref(null),
fsOps: ref<Archon.Websocket.v0.FilesystemOperation[]>([]),
@@ -1,85 +1,6 @@
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',
@@ -89,110 +10,109 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
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 Default: Story = {
name: 'Default (no progress)',
}
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',
export const WithProgress: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 45,
},
status: 'installing',
progress: 45,
error: null,
source: 'websocket',
}),
},
}
export const Vanilla: Story = {
render: renderInstallation({
id: 'platform:vanilla::1.21.1',
key: {
type: 'platform',
platform: 'vanilla',
platform_version: '',
game_version: '1.21.1',
export const IndeterminateLoaderInstall: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 0,
},
status: 'installing',
progress: 20,
error: null,
source: 'websocket',
}),
},
}
export const Modpack: Story = {
render: renderInstallation({
id: 'modrinth-modpack:project:version',
key: {
type: 'modrinth_modpack',
project_id: 'project',
version_id: 'version',
export const InstallingModpack: Story = {
args: {
progress: {
phase: 'InstallingPack',
percent: 72,
},
status: 'installing',
progress: 72,
error: null,
source: 'websocket',
}),
},
}
export const PlatformFailed: Story = {
render: renderInstallation({
id: 'platform:quilt:0.30.1-beta.2:1.14.4',
key: {
type: 'platform',
platform: 'quilt',
platform_version: '0.30.1-beta.2',
game_version: '1.14.4',
export const InstallingAddons: Story = {
args: {
progress: {
phase: 'Addons',
percent: 90,
},
status: 'failed',
progress: null,
error: 'Platform installer failed',
source: 'websocket',
}),
},
}
export const ModpackFailed: Story = {
render: renderInstallation({
id: 'modrinth-modpack:project:version',
key: {
type: 'modrinth_modpack',
project_id: 'project',
version_id: 'version',
export const ErrorInvalidVersion: Story = {
name: 'Error: Invalid Version',
args: {
contentError: {
step: 'modloader',
description: 'the specified version may be incorrect',
},
status: 'failed',
progress: null,
error: 'The modpack does not include a downloadable file',
source: 'websocket',
}),
},
}
export const LocalModpackFailed: Story = {
render: renderInstallation({
id: 'local-modpack:example.mrpack',
key: {
type: 'local_modpack',
filename: 'example.mrpack',
export const ErrorUnsupportedVersion: Story = {
name: 'Error: Unsupported Version',
args: {
contentError: {
step: 'modloader',
description: 'this version is not yet supported',
},
status: 'failed',
progress: null,
error: 'The modpack could not be read',
source: 'websocket',
},
}
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>
`,
}),
}
@@ -88,11 +88,7 @@ const meta = {
isServerRunning: computed(() => true),
stats,
uptimeSeconds: ref(0),
installProgressItems: ref<Archon.Websocket.v0.InstallProgressItem[]>([]),
installation: computed(() => null),
beginInstallation: () => {},
cancelOptimisticInstallation: () => {},
dismissInstallation: () => {},
isSyncingContent: ref(false),
busyReasons: computed(() => [
{ reason: defineMessage({ id: 's.bg', defaultMessage: 'Background task running' }) },
]),
+1
View File
@@ -11,6 +11,7 @@ export * from './notices'
export * from './project-types'
export * from './savable'
export * from './search'
export * from './server-content-installing'
export * from './server-search'
export * from './tag-messages'
export * from './truncate'
@@ -0,0 +1,203 @@
import type {
ContentCardProject,
ContentCardVersion,
ContentOwner,
} from '../layouts/shared/content-tab/types'
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
export interface PendingServerContentInstall {
projectId: string
versionId: string
contentType: PendingServerContentInstallType
title: ContentCardProject['title']
versionName?: ContentCardVersion['version_number'] | null
versionNumber?: ContentCardVersion['version_number'] | null
fileName?: ContentCardVersion['file_name'] | null
owner?: PendingServerContentOwner | null
slug?: ContentCardProject['slug'] | null
iconUrl?: ContentCardProject['icon_url'] | null
createdAt: number
}
interface PendingServerContentInstallBaseline {
contentKeys: string[]
projectIds?: string[]
createdAt: number
}
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
const stalePendingInstallAge = 30 * 60 * 1000
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `server-content-installing:${serverId}:${worldId}`
}
function getPendingServerContentInstallBaselineKey(
serverId: string | null,
worldId: string | null,
) {
if (!serverId || !worldId) return null
return `server-content-installing-baseline:${serverId}:${worldId}`
}
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
typeof record.projectId === 'string' &&
typeof record.versionId === 'string' &&
(record.contentType === 'mod' ||
record.contentType === 'plugin' ||
record.contentType === 'datapack') &&
typeof record.title === 'string' &&
typeof record.createdAt === 'number'
)
}
function isPendingServerContentInstallBaseline(
value: unknown,
): value is PendingServerContentInstallBaseline {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
const contentKeys = record.contentKeys ?? record.projectIds
return (
Array.isArray(contentKeys) &&
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
typeof record.createdAt === 'number'
)
}
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
const cutoff = Date.now() - stalePendingInstallAge
return items.filter((item) => item.createdAt >= cutoff)
}
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
return item.createdAt >= Date.now() - stalePendingInstallAge
}
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent(pendingServerContentInstallsEvent, {
detail: { serverId, worldId },
}),
)
}
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const freshItems = filterFreshPendingServerContentInstalls(
parsed.filter(isPendingServerContentInstall),
)
if (freshItems.length !== parsed.length) {
writePendingServerContentInstalls(serverId, worldId, freshItems)
}
return freshItems
} catch {
return []
}
}
export function writePendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: PendingServerContentInstall[],
) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
const freshItems = filterFreshPendingServerContentInstalls(items)
if (freshItems.length === 0) {
localStorage.removeItem(key)
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (baselineKey) {
localStorage.removeItem(baselineKey)
}
} else {
localStorage.setItem(key, JSON.stringify(freshItems))
}
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function readPendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return null
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!isPendingServerContentInstallBaseline(parsed)) return null
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
localStorage.removeItem(key)
return null
}
return new Set(parsed.contentKeys ?? parsed.projectIds)
} catch {
return null
}
}
export function writePendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
contentKeys: Iterable<string>,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
localStorage.setItem(
key,
JSON.stringify({
contentKeys: Array.from(new Set(contentKeys)),
createdAt: Date.now(),
} satisfies PendingServerContentInstallBaseline),
)
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function addPendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: Omit<PendingServerContentInstall, 'createdAt'>[],
) {
if (items.length === 0) return
const now = Date.now()
const next = new Map(
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
)
for (const item of items) {
next.set(item.projectId, { ...item, createdAt: now })
}
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
}
export function removePendingServerContentInstall(
serverId: string | null,
worldId: string | null,
projectId: string,
) {
writePendingServerContentInstalls(
serverId,
worldId,
readPendingServerContentInstalls(serverId, worldId).filter(
(item) => item.projectId !== projectId,
),
)
}