Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
1153 lines
33 KiB
Vue
1153 lines
33 KiB
Vue
<template>
|
|
<div
|
|
v-if="instance"
|
|
:class="{
|
|
'flex h-full min-h-0 flex-col': isFixedRender,
|
|
'instance-fixed-render': isFixedRender,
|
|
}"
|
|
>
|
|
<div
|
|
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender, hidden: isStudioMode }]"
|
|
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
|
|
>
|
|
<ExportModal ref="exportModal" :instance="instance" />
|
|
<InstanceSettingsModal
|
|
:key="instance.id"
|
|
ref="settingsModal"
|
|
:instance="instance"
|
|
:offline="offline"
|
|
@unlinked="fetchInstance"
|
|
/>
|
|
<UpdateToPlayModal ref="updateToPlayModal" />
|
|
<ContentPageHeader>
|
|
<template #icon>
|
|
<InstanceIcon
|
|
:icon-path="instance.icon_path"
|
|
:instance-id="instance.id"
|
|
:loader="instance.loader"
|
|
:alt="instance.name"
|
|
size="64px"
|
|
/>
|
|
</template>
|
|
<template #title>
|
|
{{ instance.name }}
|
|
</template>
|
|
<template #stats>
|
|
<div class="flex items-center flex-wrap gap-2">
|
|
<template v-if="!isServerInstance">
|
|
<div class="flex items-center gap-1.5 font-medium">
|
|
<BoxIcon class="h-4 w-4" />
|
|
{{ instance.game_version }}
|
|
</div>
|
|
<Badge
|
|
v-if="postUpgradeNotice"
|
|
color="green"
|
|
:type="
|
|
formatMessage(messages.upgradedTo, {
|
|
version: postUpgradeNotice.targetGameVersion,
|
|
})
|
|
"
|
|
/>
|
|
|
|
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
|
|
|
<div class="flex items-center gap-1.5 font-medium capitalize">
|
|
<component :is="loaderIcon" v-if="loaderIcon" class="h-4 w-4" />
|
|
{{ instance.loader }} {{ instance.loader_version ? instance.loader_version : '' }}
|
|
</div>
|
|
|
|
<template v-if="showInstancePlayTime">
|
|
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
|
|
|
<div class="flex items-center gap-2 font-medium">
|
|
<template v-if="timePlayed > 0">
|
|
{{ timePlayedHumanized }}
|
|
</template>
|
|
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<template v-if="loadingServerPing">
|
|
<ServerOnlinePlayers
|
|
v-if="playersOnline !== undefined"
|
|
:online="playersOnline"
|
|
:status-online="statusOnline"
|
|
hide-label
|
|
/>
|
|
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
|
<div
|
|
v-if="
|
|
(playersOnline !== undefined || recentPlays !== undefined) &&
|
|
(minecraftServer?.region || ping)
|
|
"
|
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
|
></div>
|
|
<ServerPing v-if="ping" :ping="ping" />
|
|
</template>
|
|
|
|
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
|
|
|
<div
|
|
v-if="minecraftServer?.region || ping"
|
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
|
></div>
|
|
|
|
<div
|
|
v-if="linkedProjectV3"
|
|
class="flex gap-1.5 items-center font-medium text-primary"
|
|
>
|
|
{{ formatMessage(messages.linkedTo) }}
|
|
<Avatar
|
|
:src="linkedProjectV3.icon_url"
|
|
:alt="linkedProjectV3.name"
|
|
:tint-by="instance.id"
|
|
size="24px"
|
|
/>
|
|
<router-link
|
|
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
|
class="hover:underline text-primary truncate"
|
|
>
|
|
{{ linkedProjectV3.name }}
|
|
</router-link>
|
|
</div>
|
|
</template>
|
|
|
|
<div
|
|
v-if="instance?.symlink_target && symlinkWarning.isHidden.value"
|
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
|
></div>
|
|
<SymlinkInstanceWarning
|
|
v-if="instance?.symlink_target && symlinkWarning.isHidden.value"
|
|
:symlink-target="instance.symlink_target"
|
|
badge-only
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #actions>
|
|
<div data-onboarding-id="instance-actions" class="flex gap-2">
|
|
<ButtonStyled
|
|
v-if="
|
|
[
|
|
'installing',
|
|
'pack_installing',
|
|
'pack_installed',
|
|
'not_installed',
|
|
'minecraft_installing',
|
|
].includes(instance.install_stage)
|
|
"
|
|
color="brand"
|
|
size="large"
|
|
>
|
|
<button disabled>{{ formatMessage(commonMessages.installingLabel) }}</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled
|
|
v-else-if="instance.install_stage !== 'installed'"
|
|
color="brand"
|
|
size="large"
|
|
>
|
|
<button
|
|
v-tooltip="offline ? formatMessage(messages.offlineInstalledOnly) : undefined"
|
|
:disabled="offline"
|
|
@click="repairInstance()"
|
|
>
|
|
<DownloadIcon />
|
|
{{ formatMessage(commonMessages.repairButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
|
<button :disabled="stopping" @click="stopInstance('InstancePage')">
|
|
<StopCircleIcon />
|
|
{{
|
|
stopping
|
|
? formatMessage(messages.stopping)
|
|
: formatMessage(commonMessages.stopButton)
|
|
}}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled
|
|
v-else-if="playing === false && loading === false && !isServerInstance"
|
|
color="brand"
|
|
size="large"
|
|
>
|
|
<button @click="startInstance('InstancePage')">
|
|
<PlayIcon />
|
|
{{ formatMessage(commonMessages.playButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<div
|
|
v-else-if="playing === false && loading === false && isServerInstance"
|
|
class="joined-buttons"
|
|
>
|
|
<ButtonStyled color="brand" size="large">
|
|
<button @click="handlePlayServer()">
|
|
<PlayIcon />
|
|
{{ formatMessage(commonMessages.playButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand" size="large">
|
|
<OverflowMenu
|
|
:options="[
|
|
{
|
|
id: 'join_server',
|
|
action: () => handlePlayServer(),
|
|
},
|
|
{
|
|
id: 'launch_instance',
|
|
action: () => startInstance('InstancePage'),
|
|
},
|
|
]"
|
|
>
|
|
<div class="w-0 text-xl relative top-0.5 right-2.5">
|
|
<DropdownIcon />
|
|
</div>
|
|
|
|
<template #join_server>
|
|
<PlayIcon />
|
|
{{ formatMessage(messages.joinServer) }}
|
|
</template>
|
|
<template #launch_instance>
|
|
<PlayIcon />
|
|
{{ formatMessage(messages.launchInstance) }}
|
|
</template>
|
|
</OverflowMenu>
|
|
</ButtonStyled>
|
|
</div>
|
|
<ButtonStyled
|
|
v-else-if="loading === true && playing === false"
|
|
color="brand"
|
|
size="large"
|
|
>
|
|
<button disabled>
|
|
<SpinnerIcon class="animate-spin" />
|
|
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular size="large">
|
|
<button
|
|
v-tooltip="formatMessage(messages.instanceSettings)"
|
|
@click="settingsModal?.show()"
|
|
>
|
|
<SettingsIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled type="transparent" circular size="large">
|
|
<OverflowMenu
|
|
:options="[
|
|
{
|
|
id: 'open-folder',
|
|
action: () => {
|
|
if (instance) showInstanceInFolder(instance.id)
|
|
},
|
|
},
|
|
{
|
|
id: 'export-mrpack',
|
|
action: () => exportModal?.show(),
|
|
},
|
|
{
|
|
id: 'create-shortcut',
|
|
action: () => createShortcut(),
|
|
},
|
|
...(canUpgradeInstance
|
|
? [{ id: 'upgrade-instance', action: () => openUpgrade() }]
|
|
: []),
|
|
]"
|
|
>
|
|
<MoreVerticalIcon />
|
|
<template #share-instance>
|
|
<UserPlusIcon /> {{ formatMessage(messages.shareInstance) }}
|
|
</template>
|
|
<template #host-a-server>
|
|
<ServerIcon /> {{ formatMessage(messages.createServer) }}
|
|
</template>
|
|
<template #open-folder>
|
|
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
|
|
</template>
|
|
<template #export-mrpack>
|
|
<PackageIcon /> {{ formatMessage(messages.exportModpack) }}
|
|
</template>
|
|
<template #create-shortcut>
|
|
<ExternalIcon /> {{ formatMessage(messages.createShortcut) }}
|
|
</template>
|
|
<template #upgrade-instance>
|
|
<UpdatedIcon /> {{ formatMessage(messages.upgradeInstance) }}
|
|
</template>
|
|
</OverflowMenu>
|
|
</ButtonStyled>
|
|
</div>
|
|
</template>
|
|
</ContentPageHeader>
|
|
</div>
|
|
<div
|
|
data-onboarding-id="instance-tabs"
|
|
:class="['px-6', { 'shrink-0': isFixedRender, hidden: isStudioMode }]"
|
|
>
|
|
<SymlinkInstanceWarning
|
|
v-if="instance?.symlink_target && !symlinkWarning.isHidden.value"
|
|
:symlink-target="instance.symlink_target"
|
|
class="mb-3"
|
|
dismissible
|
|
/>
|
|
<NavTabs v-if="!hideInstanceTabs" :links="tabs" />
|
|
</div>
|
|
<div :class="['p-6 pt-4', { 'flex min-h-0 flex-1 flex-col overflow-hidden': isFixedRender }]">
|
|
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
|
|
<template v-if="Component">
|
|
<Suspense
|
|
:key="instance.id"
|
|
@pending="subpagePending = true"
|
|
@resolve="subpagePending = false"
|
|
>
|
|
<component
|
|
:is="Component"
|
|
:instance="instance"
|
|
:options="options"
|
|
:offline="offline"
|
|
:playing="playing"
|
|
:installed="instance.install_stage !== 'installed'"
|
|
:is-server-instance="isServerInstance"
|
|
:open-settings="() => settingsModal?.show(1)"
|
|
@play="updatePlayState"
|
|
@stop="() => stopInstance('InstanceSubpage')"
|
|
></component>
|
|
</Suspense>
|
|
</template>
|
|
</RouterView>
|
|
</div>
|
|
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
|
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
|
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
|
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
|
<template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template>
|
|
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
|
<template #open_folder>
|
|
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
|
|
</template>
|
|
<template #copy_link>
|
|
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
|
|
</template>
|
|
<template #open_link>
|
|
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
|
|
</template>
|
|
<template #copy_names><EditIcon />{{ formatMessage(messages.copyNames) }}</template>
|
|
<template #copy_slugs><HashIcon />{{ formatMessage(messages.copySlugs) }}</template>
|
|
<template #copy_links><GlobeIcon />{{ formatMessage(messages.copyLinks) }}</template>
|
|
<template #toggle><EditIcon />{{ formatMessage(messages.toggleSelected) }}</template>
|
|
<template #disable><XIcon />{{ formatMessage(messages.disableSelected) }}</template>
|
|
<template #enable><CheckCircleIcon />{{ formatMessage(messages.enableSelected) }}</template>
|
|
<template #hide_show><EyeIcon />{{ formatMessage(messages.hideShowUnselected) }}</template>
|
|
<template #update_all
|
|
><UpdatedIcon />{{
|
|
selected.length > 0
|
|
? formatMessage(messages.updateSelected)
|
|
: formatMessage(messages.updateAll)
|
|
}}</template
|
|
>
|
|
<template #filter_update
|
|
><UpdatedIcon />{{ formatMessage(messages.selectUpdatable) }}</template
|
|
>
|
|
</ContextMenu>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import {
|
|
BoxesIcon,
|
|
BoxIcon,
|
|
CheckCircleIcon,
|
|
ClipboardCopyIcon,
|
|
DownloadIcon,
|
|
DropdownIcon,
|
|
EditIcon,
|
|
ExternalIcon,
|
|
EyeIcon,
|
|
FolderOpenIcon,
|
|
getLoaderIcon,
|
|
GlobeIcon,
|
|
HashIcon,
|
|
ImageIcon,
|
|
MoreVerticalIcon,
|
|
PackageIcon,
|
|
PlayIcon,
|
|
PlusIcon,
|
|
ServerIcon,
|
|
SettingsIcon,
|
|
SpinnerIcon,
|
|
StopCircleIcon,
|
|
TerminalSquareIcon,
|
|
UpdatedIcon,
|
|
UserPlusIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
ButtonStyled,
|
|
commonMessages,
|
|
ContentPageHeader,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
NavTabs,
|
|
OverflowMenu,
|
|
ServerOnlinePlayers,
|
|
ServerPing,
|
|
ServerRecentPlays,
|
|
ServerRegion,
|
|
useLoadingBarToken,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { useQueryClient } from '@tanstack/vue-query'
|
|
import dayjs from 'dayjs'
|
|
import duration from 'dayjs/plugin/duration'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
|
import ExportModal from '@/components/ui/ExportModal.vue'
|
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
|
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
|
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
|
import {
|
|
fetchCachedServerStatus,
|
|
getFreshCachedServerStatus,
|
|
} from '@/composables/instances/use-server-status-query'
|
|
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
|
import { useInstanceMode } from '@/composables/useInstanceMode'
|
|
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
|
|
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
|
|
import { get_project_v3 } from '@/helpers/cache.js'
|
|
import { instance_listener, process_listener } from '@/helpers/events'
|
|
import {
|
|
install_existing_instance,
|
|
install_job_list,
|
|
install_pack_to_existing_instance,
|
|
} from '@/helpers/install'
|
|
import {
|
|
allow_symlink_target,
|
|
gcReportFellBack,
|
|
get,
|
|
get_full_path,
|
|
kill,
|
|
run,
|
|
} from '@/helpers/instance'
|
|
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
|
import { get_by_instance_id } from '@/helpers/process'
|
|
import type { GameInstance } from '@/helpers/types'
|
|
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
|
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
|
import { injectServerInstall } from '@/providers/server-install'
|
|
import { handleSevereError } from '@/store/error.js'
|
|
import { useBreadcrumbs, useTheming } from '@/store/state'
|
|
|
|
import { isActiveUpgradeJobForInstance, isUnmanagedUpgradeEligible } from './upgrade/entry'
|
|
|
|
dayjs.extend(duration)
|
|
dayjs.extend(relativeTime)
|
|
|
|
const { addNotification, handleError } = injectNotificationManager()
|
|
const { playServerProject } = injectServerInstall()
|
|
const queryClient = useQueryClient()
|
|
const route = useRoute()
|
|
const props = defineProps<{
|
|
id: string
|
|
}>()
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const messages = defineMessages({
|
|
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
|
upgradedTo: {
|
|
id: 'app.instance.post-upgrade-status',
|
|
defaultMessage: 'Upgraded to {version}',
|
|
},
|
|
linkedTo: { id: 'app.instance.linked-to', defaultMessage: 'Linked to' },
|
|
stopping: { id: 'app.instance.stopping', defaultMessage: 'Stopping...' },
|
|
joinServer: { id: 'app.instance.join-server', defaultMessage: 'Join server' },
|
|
launchInstance: { id: 'app.instance.launch-instance', defaultMessage: 'Launch instance' },
|
|
starting: { id: 'app.instance.starting', defaultMessage: 'Starting... {seconds, number}s' },
|
|
instanceSettings: { id: 'app.instance.settings', defaultMessage: 'Instance settings' },
|
|
shareInstance: { id: 'app.instance.share', defaultMessage: 'Share instance' },
|
|
createServer: { id: 'app.instance.create-server', defaultMessage: 'Create a server' },
|
|
exportModpack: { id: 'app.instance.export-modpack', defaultMessage: 'Export modpack' },
|
|
createShortcut: { id: 'app.instance.create-shortcut', defaultMessage: 'Create shortcut' },
|
|
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
|
|
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
|
copyPath: { id: 'app.instances.copy-path', defaultMessage: 'Copy path' },
|
|
copyNames: { id: 'app.instance.copy-names', defaultMessage: 'Copy names' },
|
|
copySlugs: { id: 'app.instance.copy-slugs', defaultMessage: 'Copy slugs' },
|
|
copyLinks: { id: 'app.instance.copy-links', defaultMessage: 'Copy links' },
|
|
toggleSelected: { id: 'app.instance.toggle-selected', defaultMessage: 'Toggle selected' },
|
|
disableSelected: { id: 'app.instance.disable-selected', defaultMessage: 'Disable selected' },
|
|
enableSelected: { id: 'app.instance.enable-selected', defaultMessage: 'Enable selected' },
|
|
hideShowUnselected: {
|
|
id: 'app.instance.hide-show-unselected',
|
|
defaultMessage: 'Show/hide unselected',
|
|
},
|
|
updateSelected: { id: 'app.instance.update-selected', defaultMessage: 'Update selected' },
|
|
updateAll: { id: 'app.instance.update-all', defaultMessage: 'Update all' },
|
|
selectUpdatable: { id: 'app.instance.select-updatable', defaultMessage: 'Select updatable' },
|
|
contentTab: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
|
|
filesTab: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
|
|
worldsTab: { id: 'app.instance.tabs.worlds', defaultMessage: 'Worlds' },
|
|
screenshotsTab: { id: 'app.instance.tabs.screenshots', defaultMessage: 'Screenshots' },
|
|
logsTab: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
|
|
offlineInstalledOnly: {
|
|
id: 'app.instance.offline-installed-only',
|
|
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
|
|
},
|
|
gcFallbackNotice: {
|
|
id: 'app.gc-notice.fallback',
|
|
defaultMessage: 'Java did not accept {preferred}; using {chosen}.',
|
|
},
|
|
})
|
|
|
|
const router = useRouter()
|
|
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
|
|
const breadcrumbs = useBreadcrumbs()
|
|
const themeStore = useTheming()
|
|
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
|
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
|
|
const { offline } = useNetworkStatus()
|
|
|
|
const instance = ref<GameInstance>()
|
|
const instanceId = computed(() => instance.value?.id)
|
|
const instanceModeQuery = useInstanceMode(() => instance.value?.id ?? props.id)
|
|
const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ?? props.id)
|
|
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
|
|
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
|
|
const playing = ref(false)
|
|
const loading = ref(false)
|
|
const launchElapsedSeconds = ref(0)
|
|
const subpagePending = ref(false)
|
|
const stopping = ref(false)
|
|
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
|
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
|
let launchElapsedTimer: ReturnType<typeof setInterval> | undefined
|
|
|
|
useLoadingBarToken(subpagePending)
|
|
|
|
const isServerInstance = ref(false)
|
|
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
|
|
const selected = ref<unknown[]>([])
|
|
const canUpgradeInstance = computed(() =>
|
|
instance.value && instanceModeQuery.data.value === 'local'
|
|
? isUnmanagedUpgradeEligible(instance.value)
|
|
: false,
|
|
)
|
|
|
|
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
|
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
|
const liveServerStatusOnline = ref(false)
|
|
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
|
|
const recentPlays = computed(
|
|
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
|
)
|
|
const playersOnline = ref<number | undefined>(undefined)
|
|
const ping = ref<number | undefined>(undefined)
|
|
const loadingServerPing = ref(false)
|
|
const activeInstanceId = ref<string>()
|
|
const loaderIcon = computed(() =>
|
|
instance.value?.loader ? getLoaderIcon(instance.value.loader) : undefined,
|
|
)
|
|
|
|
watch(
|
|
() => router.currentRoute.value,
|
|
(nextRoute) => {
|
|
if (nextRoute.path.startsWith('/instance') && nextRoute.params.id === props.id) {
|
|
displayedInstanceRoute.value = nextRoute
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
function applyServerStatus(status: ServerStatus) {
|
|
playersOnline.value = status.players?.online
|
|
ping.value = status.ping
|
|
liveServerStatusOnline.value = true
|
|
loadingServerPing.value = true
|
|
}
|
|
|
|
function resetServerStatus() {
|
|
ping.value = undefined
|
|
playersOnline.value = undefined
|
|
liveServerStatusOnline.value = false
|
|
loadingServerPing.value = false
|
|
}
|
|
|
|
async function fetchInstance() {
|
|
isServerInstance.value = false
|
|
linkedProjectV3.value = undefined
|
|
resetServerStatus()
|
|
|
|
const nextInstance = await get(props.id).catch(handleError)
|
|
instance.value = nextInstance ?? undefined
|
|
activeInstanceId.value = nextInstance?.id
|
|
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
|
let nextIsServerInstance = false
|
|
|
|
if (nextInstance?.symlink_target) {
|
|
await allow_symlink_target(nextInstance.symlink_target).catch(() => {})
|
|
}
|
|
|
|
if (!offline.value && nextInstance?.link && nextInstance.link.project_id) {
|
|
try {
|
|
nextLinkedProjectV3 = await get_project_v3(nextInstance.link.project_id, 'must_revalidate')
|
|
|
|
if (nextLinkedProjectV3?.minecraft_server != null) {
|
|
nextIsServerInstance = true
|
|
}
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
}
|
|
}
|
|
|
|
linkedProjectV3.value = nextLinkedProjectV3
|
|
isServerInstance.value = nextIsServerInstance
|
|
|
|
fetchDeferredData(nextInstance?.id)
|
|
|
|
if (nextInstance) {
|
|
queryClient.prefetchQuery({
|
|
queryKey: ['worlds', nextInstance.id],
|
|
queryFn: () => refreshWorlds(nextInstance.id),
|
|
staleTime: 30_000,
|
|
})
|
|
}
|
|
}
|
|
|
|
function fetchDeferredData(instanceId?: string) {
|
|
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
|
if (isServerInstance.value && serverAddress) {
|
|
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
|
if (cachedStatus) {
|
|
applyServerStatus(cachedStatus)
|
|
} else {
|
|
playersOnline.value = undefined
|
|
ping.value = undefined
|
|
loadingServerPing.value = false
|
|
}
|
|
|
|
fetchCachedServerStatus(queryClient, serverAddress)
|
|
.then((status) => {
|
|
if (
|
|
activeInstanceId.value !== instanceId ||
|
|
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
|
)
|
|
return
|
|
applyServerStatus(status)
|
|
})
|
|
.catch((error) => {
|
|
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
|
})
|
|
.finally(() => {
|
|
if (activeInstanceId.value !== instanceId) return
|
|
loadingServerPing.value = true
|
|
})
|
|
} else {
|
|
loadingServerPing.value = true
|
|
}
|
|
|
|
updatePlayState()
|
|
}
|
|
|
|
async function updatePlayState() {
|
|
const runningProcesses = await get_by_instance_id(props.id).catch(handleError)
|
|
|
|
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
|
}
|
|
|
|
await fetchInstance()
|
|
watch(
|
|
() => props.id,
|
|
async () => {
|
|
if (route.path.startsWith('/instance')) {
|
|
await fetchInstance()
|
|
}
|
|
},
|
|
)
|
|
|
|
const basePath = computed(
|
|
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
|
|
)
|
|
|
|
/**
|
|
* Per-route layout mode.
|
|
* - `'scroll'` (default): the whole instance page scrolls inside `.app-viewport`. This lets
|
|
* `position: sticky` children (and the viewport-rooted `IntersectionObserver` used by
|
|
* `useStickyObserver`) work correctly.
|
|
* - `'fixed'`: the header + tabs are pinned and only the tab body scrolls in its own container.
|
|
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
|
|
*/
|
|
const renderMode = computed<'scroll' | 'fixed'>(() =>
|
|
displayedInstanceRoute.value.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
|
|
)
|
|
const isFixedRender = computed(() => renderMode.value === 'fixed')
|
|
const hideInstanceTabs = computed(() =>
|
|
displayedInstanceRoute.value.matched.some((record) => record.meta.hideInstanceTabs === true),
|
|
)
|
|
const isStudioMode = computed(() => displayedInstanceRoute.value.name === 'FileStudio')
|
|
const tabs = computed(() => [
|
|
{
|
|
label: formatMessage(messages.contentTab),
|
|
href: `${basePath.value}`,
|
|
icon: BoxesIcon,
|
|
},
|
|
{
|
|
label: formatMessage(messages.filesTab),
|
|
href: `${basePath.value}/files`,
|
|
icon: FolderOpenIcon,
|
|
},
|
|
{
|
|
label: formatMessage(messages.screenshotsTab),
|
|
href: `${basePath.value}/screenshots`,
|
|
icon: ImageIcon,
|
|
},
|
|
{
|
|
label: formatMessage(messages.worldsTab),
|
|
href: `${basePath.value}/worlds`,
|
|
icon: GlobeIcon,
|
|
},
|
|
{
|
|
label: formatMessage(messages.logsTab),
|
|
href: `${basePath.value}/logs`,
|
|
icon: TerminalSquareIcon,
|
|
},
|
|
])
|
|
|
|
function updateBreadcrumbs() {
|
|
if (instance.value) {
|
|
breadcrumbs.setRootContext({
|
|
name: instance.value.name,
|
|
link: displayedInstanceRoute.value.path,
|
|
query: displayedInstanceRoute.value.query,
|
|
iconUrl: getDisplayInstanceIcon(instance.value.icon_path, instance.value.loader).url,
|
|
})
|
|
}
|
|
}
|
|
|
|
updateBreadcrumbs()
|
|
|
|
watch(
|
|
() => instance.value?.id,
|
|
() => {
|
|
updateBreadcrumbs()
|
|
},
|
|
)
|
|
|
|
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
|
|
|
const startInstance = async (context: string) => {
|
|
if (!instance.value) return
|
|
if (!offline.value && updateToPlayModal.value?.hasUpdate) {
|
|
updateToPlayModal.value.show(instance.value)
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
launchElapsedSeconds.value = 0
|
|
launchElapsedTimer = setInterval(() => {
|
|
launchElapsedSeconds.value += 1
|
|
}, 1000)
|
|
try {
|
|
const result = await run(props.id)
|
|
const gcNotice = result.gc_notice
|
|
if (gcNotice && gcReportFellBack(gcNotice)) {
|
|
addNotification({
|
|
type: 'warning',
|
|
title: formatMessage(messages.gcFallbackNotice, {
|
|
preferred: gcNotice.preferred_strategy || 'auto',
|
|
chosen: gcNotice.chosen_strategy || 'JVM default GC',
|
|
}),
|
|
})
|
|
}
|
|
playing.value = true
|
|
} catch (err) {
|
|
const handled = await handleMinecraftLaunchError(err, {
|
|
instance_id: props.id,
|
|
instance_name: instance.value.name,
|
|
})
|
|
if (!handled) {
|
|
handleSevereError(err, { instanceId: props.id })
|
|
}
|
|
} finally {
|
|
clearInterval(launchElapsedTimer)
|
|
launchElapsedTimer = undefined
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const stopInstance = async (context: string) => {
|
|
stopping.value = true
|
|
await kill(props.id).catch(handleError)
|
|
stopping.value = false
|
|
playing.value = false
|
|
|
|
if (!instance.value) return
|
|
}
|
|
|
|
const handlePlayServer = async () => {
|
|
if (!instance.value?.link?.project_id) return
|
|
loading.value = true
|
|
try {
|
|
await playServerProject(instance.value.link.project_id)
|
|
} finally {
|
|
await updatePlayState()
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const repairInstance = async () => {
|
|
if (
|
|
instance.value.install_stage !== 'pack_installed' &&
|
|
(instance.value.link?.type === 'modrinth_modpack' ||
|
|
instance.value.link?.type === 'server_project_modpack')
|
|
) {
|
|
await install_pack_to_existing_instance(instance.value.id, {
|
|
type: 'fromVersionId',
|
|
project_id: instance.value.link.project_id ?? instance.value.link.server_project_id ?? '',
|
|
version_id: instance.value.link.version_id ?? instance.value.link.content_version_id ?? '',
|
|
title: instance.value.name,
|
|
}).catch(handleError)
|
|
} else {
|
|
await install_existing_instance(instance.value.id, false).catch(handleError)
|
|
}
|
|
}
|
|
|
|
const createShortcut = async () => {
|
|
if (!instance.value) return
|
|
try {
|
|
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
|
|
if (!shortcutPath) return
|
|
|
|
addNotification({
|
|
type: 'success',
|
|
title: 'Shortcut created',
|
|
})
|
|
} catch (error: unknown) {
|
|
addNotification({
|
|
type: 'error',
|
|
title: `Error creating shortcut`,
|
|
text: `${error}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
const openUpgrade = async () => {
|
|
if (!instance.value) return
|
|
const active = (await install_job_list(true).catch(() => [])).find((job) =>
|
|
isActiveUpgradeJobForInstance(job, instance.value!.id),
|
|
)
|
|
if (active) {
|
|
await router.push({ path: '/downloads', query: { job: active.job_id } })
|
|
return
|
|
}
|
|
await router.push(`/instance/${encodeURIComponent(instance.value.id)}/upgrade`)
|
|
}
|
|
|
|
const handleRightClick = (event: MouseEvent) => {
|
|
const baseOptions = [
|
|
{ name: 'add_content' },
|
|
{ type: 'divider' },
|
|
{ name: 'edit' },
|
|
{ name: 'open_folder' },
|
|
{ name: 'copy_path' },
|
|
]
|
|
|
|
options.value?.showMenu(
|
|
event,
|
|
instance.value,
|
|
playing.value
|
|
? [
|
|
{
|
|
name: 'stop',
|
|
color: 'danger',
|
|
},
|
|
...baseOptions,
|
|
]
|
|
: [
|
|
{
|
|
name: 'play',
|
|
color: 'primary',
|
|
},
|
|
...baseOptions,
|
|
],
|
|
)
|
|
}
|
|
|
|
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
|
|
switch (args.option) {
|
|
case 'play':
|
|
await startInstance('InstancePageContextMenu')
|
|
break
|
|
case 'stop':
|
|
await stopInstance('InstancePageContextMenu')
|
|
break
|
|
case 'add_content':
|
|
await router.push({
|
|
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
|
query: { i: props.id },
|
|
})
|
|
break
|
|
case 'edit':
|
|
settingsModal.value?.show()
|
|
break
|
|
case 'open_folder':
|
|
if (instance.value) await showInstanceInFolder(instance.value.id)
|
|
break
|
|
case 'copy_path': {
|
|
if (instance.value) {
|
|
const fullPath = await get_full_path(instance.value.id)
|
|
await navigator.clipboard.writeText(fullPath)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const unlistenInstances = await instance_listener(
|
|
async (event: { instance_id: string; event: string }) => {
|
|
if (event.instance_id !== props.id) return
|
|
if (event.event === 'removed' || route.path === '/') {
|
|
if (route.path !== '/') {
|
|
await router.push({ path: '/' })
|
|
}
|
|
return
|
|
}
|
|
instance.value = await get(props.id).catch((err) => {
|
|
if (String(err).includes('not managed')) {
|
|
router.push({ path: '/' })
|
|
return undefined
|
|
}
|
|
return handleError(err)
|
|
})
|
|
if (!instance.value?.link?.project_id) {
|
|
linkedProjectV3.value = undefined
|
|
isServerInstance.value = false
|
|
}
|
|
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
|
|
},
|
|
)
|
|
|
|
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
|
|
if (e.event === 'finished' && e.instance_id === props.id) {
|
|
playing.value = false
|
|
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
|
|
}
|
|
})
|
|
|
|
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
|
|
|
|
const timePlayed = computed(() => {
|
|
return instance.value
|
|
? instance.value.recent_time_played + instance.value.submitted_time_played
|
|
: 0
|
|
})
|
|
|
|
const timePlayedHumanized = computed(() => {
|
|
const duration = dayjs.duration(timePlayed.value, 'seconds')
|
|
const hours = Math.floor(duration.asHours())
|
|
if (hours >= 1) {
|
|
return hours + ' hour' + (hours > 1 ? 's' : '')
|
|
}
|
|
|
|
const minutes = Math.floor(duration.asMinutes())
|
|
if (minutes >= 1) {
|
|
return minutes + ' minute' + (minutes > 1 ? 's' : '')
|
|
}
|
|
|
|
const seconds = Math.floor(duration.asSeconds())
|
|
return seconds + ' second' + (seconds > 1 ? 's' : '')
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(launchElapsedTimer)
|
|
unlistenProcesses()
|
|
unlistenInstances()
|
|
const instanceId = displayedInstanceRoute.value.params.id
|
|
if (instanceId) {
|
|
const { destroy } = useInstanceConsole(instanceId)
|
|
destroy()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
Button {
|
|
width: 100%;
|
|
}
|
|
|
|
.side-cards {
|
|
position: fixed;
|
|
width: 300px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
min-height: calc(100vh - 3.25rem);
|
|
max-height: calc(100vh - 3.25rem);
|
|
overflow-y: auto;
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
|
|
&::-webkit-scrollbar {
|
|
width: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.card {
|
|
min-height: unset;
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
.instance-nav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
gap: 0.5rem;
|
|
background: var(--color-raised-bg);
|
|
height: 100%;
|
|
}
|
|
|
|
.name {
|
|
font-size: 1.25rem;
|
|
color: var(--color-contrast);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.instance-container {
|
|
display: flex;
|
|
flex-direction: row;
|
|
overflow: auto;
|
|
gap: 1rem;
|
|
min-height: 100%;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.badge {
|
|
display: flex;
|
|
align-items: center;
|
|
font-weight: bold;
|
|
width: fit-content;
|
|
color: var(--color-orange);
|
|
}
|
|
|
|
.pages-list {
|
|
.btn {
|
|
font-size: 100%;
|
|
font-weight: 400;
|
|
background: inherit;
|
|
transition: all ease-in-out 0.1s;
|
|
width: 100%;
|
|
color: var(--color-primary);
|
|
box-shadow: none;
|
|
|
|
&.router-link-exact-active {
|
|
box-shadow: var(--shadow-inset-lg);
|
|
background: var(--color-button-bg);
|
|
color: var(--color-contrast);
|
|
}
|
|
|
|
&:hover {
|
|
background-color: var(--color-button-bg);
|
|
color: var(--color-contrast);
|
|
box-shadow: var(--shadow-inset-lg);
|
|
text-decoration: none;
|
|
}
|
|
|
|
svg {
|
|
width: 1.3rem;
|
|
height: 1.3rem;
|
|
}
|
|
}
|
|
}
|
|
|
|
.instance-nav {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
justify-content: left;
|
|
padding: 1rem;
|
|
gap: 0.5rem;
|
|
height: min-content;
|
|
width: 100%;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-start;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.content {
|
|
margin: 0 1rem 0.5rem 20rem;
|
|
width: calc(100% - 20rem);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: auto;
|
|
}
|
|
|
|
.stats {
|
|
grid-area: stats;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-wrap: wrap;
|
|
gap: var(--gap-md);
|
|
|
|
.stat {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
width: fit-content;
|
|
gap: var(--gap-xs);
|
|
--stat-strong-size: 1.25rem;
|
|
|
|
strong {
|
|
font-size: var(--stat-strong-size);
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
svg {
|
|
height: var(--stat-strong-size);
|
|
width: var(--stat-strong-size);
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 750px) {
|
|
flex-direction: row;
|
|
column-gap: var(--gap-md);
|
|
margin-top: var(--gap-xs);
|
|
}
|
|
|
|
@media screen and (max-width: 600px) {
|
|
margin-top: 0;
|
|
|
|
.stat-label {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.app-viewport:has(.instance-fixed-render) {
|
|
scrollbar-gutter: auto;
|
|
}
|
|
|
|
.app-viewport:has(.instance-fixed-render) .page-transition-grid,
|
|
.app-viewport:has(.instance-fixed-render) .page-transition-layer {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
</style>
|