feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -51,10 +51,7 @@
/>
</div>
<div
v-else-if="
server.moduleErrors?.general?.error.statusCode === 403 ||
server.moduleErrors?.general?.error.statusCode === 404
"
v-else-if="serverError?.statusCode === 403 || serverError?.statusCode === 404"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -67,7 +64,7 @@
/>
</div>
<div
v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
v-else-if="serverError || !nodeAccessible"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -95,39 +92,18 @@
</template>
</ErrorInformationCard>
</div>
<!-- <div
v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Connection lost"
description=""
:icon="TransferIcon"
icon-color="orange"
:action="connectionLostAction"
>
<template #description>
<div class="space-y-4">
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue.
</p>
</div>
</template>
</ErrorInformationCard>
</div> -->
<!-- SERVER START -->
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-12 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
'--server-bg-image': serverImage
? `url(${serverImage})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div>
<div class="border-0 border-b border-solid border-divider pb-4">
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
@@ -135,7 +111,7 @@
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
<ServerIcon
:image="
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
"
class="drop-shadow-lg sm:drop-shadow-none"
/>
@@ -163,7 +139,9 @@
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
:busy-reason="
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
"
@action="sendPowerAction"
/>
</div>
@@ -188,26 +166,7 @@
</div>
</div>
<template v-if="serverData.flows?.intro">
<div
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<PanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<ServerInstallation
:server="server as ModrinthServer"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
</template>
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
<template v-else>
<div
@@ -309,7 +268,7 @@
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown :server-id="server.serverId" />
<MedalServerCountdown :server-id="serverId" />
</div>
<div
@@ -330,21 +289,28 @@
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<ServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<PanelSpinner class="!h-3 !w-3" />
<InstallingTicker />
</div>
</div>
</div>
<InstallingBanner
v-if="
(serverData.status === 'installing' || isSyncingContent) &&
syncProgress?.phase !== 'Analyzing'
"
data-pyro-server-installing
class="mb-4"
:progress="syncProgress"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
</Transition>
<NuxtPage
:route="route"
:is-connected="isConnected"
@@ -353,9 +319,8 @@
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
</div>
</template>
@@ -366,7 +331,7 @@
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
safeStringify(server)
safeStringify(serverData)
}}</pre>
</div>
</template>
@@ -374,7 +339,7 @@
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon } from '@modrinth/api-client'
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
import { clearNodeAuthState, ModrinthApiError, setNodeAuthState } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -390,37 +355,41 @@ import {
SettingsIcon,
TransferIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@modrinth/ui'
import type { BusyReason } from '@modrinth/ui'
import {
ButtonStyled,
defineMessage,
ErrorInformationCard,
formatLoaderLabel,
injectModrinthClient,
injectNotificationManager,
InstallingBanner,
provideModrinthServerContext,
ServerIcon,
ServerInfoLabels,
ServerNotice,
ServerOnboardingPanelPage,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { PowerAction, Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, type Reactive, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { useServerImage } from '~/composables/servers/use-server-image.ts'
import { useServerProject } from '~/composables/servers/use-server-project.ts'
import { useModrinthServersConsole } from '~/store/console.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const isReconnecting = ref(false)
@@ -440,26 +409,46 @@ const createdAt = ref(
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
)
const debug = useDebugLogger('ServerManage')
const route = useNativeRoute()
const router = useRouter()
const serverId = route.params.id as string
// TODO: ditch useModrinthServers for this + ctx DI.
const { data: n_server } = useQuery({
const { data: serverData, error: serverQueryError } = useQuery({
queryKey: ['servers', 'detail', serverId],
queryFn: () => client.archon.servers_v0.get(serverId)!,
})
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ['general', 'ws'])
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
if (!serverData.value) return
queryClient.setQueryData(['servers', 'detail', serverId], {
...serverData.value,
...patch,
})
}
const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === 'suspended') {
return
}
return server.refresh(['content', 'backups', 'network', 'startup'])
const serverError = computed(() => {
const err = serverQueryError.value
if (err instanceof ModrinthApiError) return err
return err ? ModrinthApiError.fromUnknown(err) : null
})
provide('modulesLoaded', loadModulesPromise)
const { data: serverFull } = useQuery({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
})
const worldId = computed(() => {
if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const serverImage = useServerImage(
serverId,
computed(() => serverData.value?.upstream ?? null),
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const errorTitle = ref('Error')
const errorMessage = ref('An unexpected error occurred.')
@@ -483,7 +472,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
)
}
const serverData = computed(() => server.general)
const isConnected = ref(false)
const isWSAuthIncorrect = ref(false)
const modrinthServersConsole = useModrinthServersConsole()
@@ -502,6 +490,70 @@ const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
// Parthenon state event
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const syncProgressActive = 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
} else if (syncProgressActive.value) {
startSyncHide()
}
})
const isSyncingContent = computed(
() => syncProgressActive.value || isAwaitingPostInstallRefresh.value,
)
const busyReasons = computed(() => {
const reasons: BusyReason[] = []
if (serverData.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
defaultMessage: 'Server is installing',
}),
})
}
if (isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
return reasons
})
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
@@ -520,12 +572,15 @@ setNodeAuthState(() => fsAuth.value, refreshFsAuth)
provideModrinthServerContext({
serverId,
server: n_server as Ref<Archon.Servers.v0.Server>,
worldId,
server: serverData as Ref<Archon.Servers.v0.Server>,
isConnected,
powerState: serverPowerState,
isServerRunning,
backupsState,
markBackupCancelled,
isSyncingContent,
busyReasons,
fsAuth,
fsOps,
fsQueuedOps,
@@ -665,8 +720,8 @@ const popupOptions = computed(
server_id: serverData.value?.server_id,
loader: serverData.value?.loader,
game_version: serverData.value?.mc_version,
modpack_id: serverData.value?.project?.id,
modpack_name: serverData.value?.project?.title,
modpack_id: serverProject.value?.id,
modpack_name: serverProject.value?.title,
},
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
onClose: async () => await dismissSurvey(),
@@ -736,6 +791,57 @@ const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
}
}
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
debug('[id.vue] handleState received:', {
power_variant: data.power_variant,
progress: data.progress,
serverStatus: serverData.value?.status,
})
syncProgress.value = data.progress
// Sync power state from the state event
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
{
not_ready: 'stopped',
starting: 'starting',
running: 'running',
stopping: 'stopping',
idle:
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
}
updatePowerState(powerMap[data.power_variant], {
exit_code: data.exit_code ?? undefined,
oom_killed: data.was_oom,
})
// Sync uptime
if (data.uptime > 0) {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
startUptimeUpdates()
}
// Update installing status from progress presence
if (serverData.value) {
if (data.progress != null && serverData.value.status !== 'installing') {
debug('[id.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 &&
serverData.value.status === 'installing' &&
hasSeenInstallProgress
) {
debug('[id.vue] handleState: progress null + was installing, applying optimistic update')
hasSeenInstallProgress = false
applyOptimisticCompletion()
invalidateAfterInstall()
}
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
@@ -847,21 +953,27 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
}
const handleNewMod = () => {
server.refresh(['content'])
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
let hasSeenInstallProgress = false
const onReinstall = async (potentialArgs: any) => {
debug('[id.vue] onReinstall called with:', potentialArgs)
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
server.general?.endIntro()
await client.archon.servers_v1.endIntro(serverId)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
if (!serverData.value) return
serverData.value.status = 'installing'
debug('[id.vue] onReinstall: setting serverData.status to installing')
hasSeenInstallProgress = false
updateServerData({ status: 'installing' })
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader
@@ -873,52 +985,110 @@ const onReinstall = (potentialArgs: any) => {
newMCVersion.value = potentialArgs.mVersion
}
debug('[id.vue] onReinstall: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
error.value = null
errorTitle.value = 'Error'
errorMessage.value = 'An unexpected error occurred.'
// Immediately refetch so loader.vue has fresh data (buttons stay locked via isSyncingContent)
debug('[id.vue] onReinstall: triggering immediate invalidation for loader.vue')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const onReinstallFailed = () => {
debug('[id.vue] onReinstallFailed: reverting status to available')
updateServerData({ status: 'available' })
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
function applyOptimisticCompletion() {
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
if (newMCVersion.value) patch.mc_version = newMCVersion.value
debug('[id.vue] applyOptimisticCompletion: patch:', patch)
updateServerData(patch)
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
queryKey: ['content', 'list', 'v1', serverId],
})
debug(
'[id.vue] applyOptimisticCompletion: found',
addonsQueries.length,
'addons queries to patch',
)
for (const [key, data] of addonsQueries) {
if (!data) continue
const addonsPatch: Record<string, string> = {}
if (newLoader.value) addonsPatch.modloader = newLoader.value
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
if (Object.keys(addonsPatch).length > 0) {
debug('[id.vue] applyOptimisticCompletion: patching addons cache:', addonsPatch)
queryClient.setQueryData(key, { ...data, ...addonsPatch })
}
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
async function invalidateAfterInstall() {
debug(
'[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=true, scheduling 2s delayed invalidation',
)
isAwaitingPostInstallRefresh.value = true
setTimeout(async () => {
debug('[id.vue] invalidateAfterInstall: delayed invalidation firing now')
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
debug('[id.vue] invalidateAfterInstall: delayed invalidation complete')
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
} finally {
debug('[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=false')
isAwaitingPostInstallRefresh.value = false
}
}, 2000)
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
debug('[id.vue] handleInstallationResult received:', data)
switch (data.result) {
case 'ok': {
debug('[id.vue] handleInstallationResult: ok received')
if (!serverData.value) break
try {
await new Promise((resolve) => setTimeout(resolve, 2000))
debug('[id.vue] handleInstallationResult: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
debug('[id.vue] handleInstallationResult: current serverData:', {
status: serverData.value.status,
loader: serverData.value.loader,
loader_version: serverData.value.loader_version,
mc_version: serverData.value.mc_version,
})
let attempts = 0
const maxAttempts = 3
let hasValidData = false
while (!hasValidData && attempts < maxAttempts) {
attempts++
await server.refresh(['general'], {
preserveConnection: true,
preserveInstallState: true,
})
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true
serverData.value.status = 'available'
await server.refresh(['content', 'startup'])
break
}
await new Promise((resolve) => setTimeout(resolve, 2000))
}
if (!hasValidData) {
console.error('Failed to get valid server data after installation')
}
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
applyOptimisticCompletion()
error.value = null
invalidateAfterInstall()
break
}
case 'err': {
@@ -1010,7 +1180,7 @@ const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
try {
isActioning.value = true
await server.general?.power(action)
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error)
notifyError(
@@ -1030,46 +1200,24 @@ const notifyError = (title: string, text: string) => {
})
}
export type BackupInProgressReason = {
type: string
tooltip: MessageDescriptor
}
const restoreInProgressReason = {
type: 'restore',
tooltip: defineMessage({
id: 'servers.backup.restore.in-progress.tooltip',
defaultMessage: 'Backup restore in progress',
}),
} satisfies BackupInProgressReason
const backupInProgress = computed(() => {
for (const entry of backupsState.values()) {
if (entry.restore?.state === 'ongoing') {
return restoreInProgressReason
}
}
return undefined
})
const nodeUnavailableDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Node',
value:
(server.moduleErrors?.general?.error.responseData as any)?.hostname ??
server.general?.datacenter ??
(serverError.value?.responseData as any)?.hostname ??
serverData.value?.datacenter ??
'Unknown',
type: 'inline' as const,
},
{
label: 'Error message',
value: nodeAccessible.value
? (server.moduleErrors?.general?.error.message ?? 'Unknown')
? (serverError.value?.message ?? 'Unknown')
: 'Unable to reach node. Ping test failed.',
type: 'block' as const,
},
@@ -1088,38 +1236,38 @@ const suspendedDescription = computed(() => {
const generalErrorDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Timestamp',
value: String(server.moduleErrors?.general?.timestamp),
value: String(new Date().toISOString()),
type: 'inline' as const,
},
{
label: 'Error Name',
value: server.moduleErrors?.general?.error.name,
value: serverError.value?.name,
type: 'inline' as const,
},
{
label: 'Error Message',
value: server.moduleErrors?.general?.error.message,
value: serverError.value?.message,
type: 'block' as const,
},
...(server.moduleErrors?.general?.error.originalError
...(serverError.value?.originalError
? [
{
label: 'Original Error',
value: String(server.moduleErrors.general.error.originalError),
value: String(serverError.value.originalError),
type: 'hidden' as const,
},
]
: []),
...(server.moduleErrors?.general?.error.stack
...(serverError.value?.stack
? [
{
label: 'Stack Trace',
value: server.moduleErrors.general.error.stack,
value: serverError.value.stack,
type: 'hidden' as const,
},
]
@@ -1186,35 +1334,70 @@ const cleanup = () => {
}
async function dismissNotice(noticeId: number) {
await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
method: 'POST',
}).catch((err) => {
await client.archon.servers_v0.dismissNotice(serverId, noticeId).catch((err) => {
addNotification({
title: 'Error dismissing notice',
text: err,
type: 'error',
})
})
await server.refresh(['general'])
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
const nodeAccessible = ref(true)
onMounted(() => {
isMounted.value = true
if (server.general?.status === 'suspended') {
async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) {
console.warn('No node instance available for ping test')
return false
}
const wsUrl = `wss://${nodeInstance}/pingtest`
try {
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 ${wsUrl}:`, error)
return false
}
}
function initializeServer() {
if (serverData.value?.status === 'suspended') {
isLoading.value = false
return
}
// Skip node test if node is null (upgrading/provisioning)
if (server.general?.node === null) {
if (serverData.value?.node === null) {
isLoading.value = false
return
}
server
.testNodeReachability()
testNodeReachability()
.then((result) => {
nodeAccessible.value = result
if (!nodeAccessible.value) {
@@ -1227,7 +1410,7 @@ onMounted(() => {
isLoading.value = false
})
if (server.moduleErrors.general?.error) {
if (serverError.value) {
isLoading.value = false
} else {
client.archon.sockets
@@ -1244,6 +1427,7 @@ onMounted(() => {
unsubscribers.value = [
client.archon.sockets.on(serverId, 'log', handleLog),
client.archon.sockets.on(serverId, 'stats', handleStats),
client.archon.sockets.on(serverId, 'state', handleState),
client.archon.sockets.on(serverId, 'power-state', handlePowerState),
client.archon.sockets.on(serverId, 'uptime', handleUptime),
client.archon.sockets.on(serverId, 'auth-incorrect', handleAuthIncorrect),
@@ -1255,14 +1439,33 @@ onMounted(() => {
]
})
.catch((error) => {
console.error('Failed to connect WebSocket:', error)
debug('[id.vue] Failed to connect WebSocket:', error)
isConnected.value = false
isLoading.value = false
})
}
if (server.general?.flows?.intro && server.general?.project) {
server.general?.endIntro()
if (serverData.value?.flows?.intro && serverProject.value) {
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
}
}
onMounted(() => {
isMounted.value = true
// serverData comes from useQuery and may not be available yet at mount time.
// Wait for it before initializing WebSocket, node reachability, etc.
if (serverData.value) {
initializeServer()
} else {
const stopWatch = watch(serverData, (data) => {
if (data) {
stopWatch()
initializeServer()
}
})
}
if (username.value && email.value && userId.value && createdAt.value) {

View File

@@ -1,21 +1,14 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { injectModrinthServerContext, ServersManageContentPage } from '@modrinth/ui'
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const { server } = injectModrinthServerContext()
const flags = useFeatureFlags()
useHead({
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
})
</script>
<template>
<ServersManageContentPage :show-client-only-filter="flags.developerMode" />
</template>

View File

@@ -1,704 +0,0 @@
<template>
<ContentVersionEditModal
v-if="!invalidModal"
ref="versionEditModal"
:type="type"
:mod-pack="Boolean(props.server.general?.upstream)"
:game-version="props.server.general?.mc_version ?? ''"
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
:server-id="props.server.serverId"
@change-version="changeModVersion($event)"
/>
<div
v-if="server.moduleErrors.content"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.content.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
<div class="flex w-full items-center gap-2">
<div class="flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<StyledInput
id="search"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
@input="debouncedSearch"
/>
</div>
<ButtonStyled>
<TeleportOverflowMenu
position="bottom"
direction="left"
:aria-label="`Filter ${type}s`"
:options="[
{ id: 'all', action: () => (filterMethod = 'all') },
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
]"
>
<span class="hidden whitespace-pre sm:block">
{{ filterMethodLabel }}
</span>
<FilterIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
<template #enabled> Only enabled </template>
<template #disabled> Only disabled </template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
<ButtonStyled>
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<FilesUploadDropdown
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])"
/>
<FilesUploadDragAndDrop
v-if="server.general && localMods"
class="relative min-h-[50vh]"
overlay-class="rounded-xl border-2 border-dashed border-secondary"
:type="type"
@files-dropped="handleDroppedFiles"
>
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
}"
>
<template v-for="mod in visibleItems.items" :key="getStableModKey(mod)">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=${type.toLocaleLowerCase()}s`
"
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
draggable="false"
>
<Avatar
:src="mod.icon_url"
size="sm"
alt="Server Icon"
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
/>
<div class="flex min-w-0 flex-col gap-1">
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
<span
v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
>Disabled</span
>
</span>
<div class="min-w-0 text-xs text-secondary">
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
<span class="block font-semibold sm:hidden">
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
</span>
</div>
</div>
</NuxtLink>
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
<div class="truncate font-semibold text-contrast">
<span v-tooltip="`${type} version`">{{
mod.version_number || `External ${type.toLocaleLowerCase()}`
}}</span>
</div>
<div class="truncate">
<span v-tooltip="`${type} file name`">
{{ mod.filename }}
</span>
</div>
</div>
<div
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
>
<ButtonStyled color="red" type="transparent">
<button
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="
mod.project_id
? `Edit ${type.toLocaleLowerCase()} version`
: `External ${type.toLocaleLowerCase()}s cannot be edited`
"
:disabled="mod.changing || !mod.project_id"
class="!hidden sm:!block"
@click="showVersionModal(mod)"
>
<template v-if="mod.changing">
<LoadingIcon class="animate-spin" />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<LoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<TeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => showVersionModal(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<Toggle
:id="`toggle-${getStableModKey(mod)}`"
:model-value="!mod.disabled"
:disabled="mod.changing"
@update:model-value="toggleMod(mod)"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- no mods has platform -->
<div
v-else-if="
props.server.general?.loader &&
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<div
v-if="!hasFilteredMods && hasMods"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<SearchIcon class="size-24" />
<p class="m-0 font-bold text-contrast">
No {{ type.toLocaleLowerCase() }}s found for your query!
</p>
<p class="m-0">Try another query, or show everything.</p>
<ButtonStyled>
<button @click="showAll">
<ListIcon />
Show everything
</button>
</ButtonStyled>
</div>
<div
v-else
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<PackageClosedIcon class="size-24" />
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
<p class="m-0">
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled type="outlined">
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<LoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
supports {{ type }}s.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled class="mt-8">
<NuxtLink :to="`/discover/modpacks?sid=${props.server.serverId}`">
<CompassIcon />
Find a modpack
</NuxtLink>
</ButtonStyled>
<div>or</div>
<ButtonStyled class="mt-8">
<NuxtLink :to="`/hosting/manage/${props.server.serverId}/options/loader`">
<WrenchIcon />
Change platform
</NuxtLink>
</ButtonStyled>
</div>
</div>
</FilesUploadDragAndDrop>
</div>
</div>
</template>
<script setup lang="ts">
import {
CompassIcon,
DropdownIcon,
EditIcon,
FileIcon,
FilterIcon,
IssuesIcon,
ListIcon,
MoreVerticalIcon,
PackageClosedIcon,
PlusIcon,
SearchIcon,
TrashIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ContentVersionEditModal from '~/components/ui/servers/ContentVersionEditModal.vue'
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import LoadingIcon from '~/components/ui/servers/icons/LoadingIcon.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
const type = computed(() => {
const loader = props.server.general?.loader?.toLowerCase()
return loader === 'paper' || loader === 'purpur' ? 'Plugin' : 'Mod'
})
interface ContentItem extends Mod {
changing?: boolean
}
const ITEM_HEIGHT = 72
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const localMods = ref<ContentItem[]>([])
const searchInput = ref('')
const modSearchInput = ref('')
const filterMethod = ref('all')
const uploadDropdownRef = ref()
const versionEditModal = ref()
const currentEditMod = ref<ContentItem | null>(null)
const invalidModal = computed(
() => !props.server.general?.mc_version || !props.server.general?.loader,
)
async function changeModVersion(event: string) {
const mod = currentEditMod.value
if (mod) mod.changing = true
try {
versionEditModal.value.hide()
// This will be used instead once backend implementation is done
// await props.server.content?.reinstall(
// `/${type.value.toLowerCase()}s/${event.fileName}`,
// currentMod.value.project_id,
// currentVersion.value.id,
// );
await props.server.content?.install(
type.value.toLowerCase() as 'mod' | 'plugin',
mod?.project_id || '',
event,
)
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
const errmsg = `Error changing mod version: ${error}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
if (mod) mod.changing = false
}
function showVersionModal(mod: ContentItem) {
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
const errmsg = invalidModal.value
? 'Data required for changing mod version was not found.'
: `${!mod?.project_id ? 'No mod project ID found' : 'No mod filename found'} for ${friendlyModName(mod!)}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
currentEditMod.value = mod
versionEditModal.value.show(mod)
}
const handleDroppedFiles = (files: File[]) => {
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
const initiateFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = acceptFileFromProjectType(type.value.toLowerCase())
input.multiple = true
input.onchange = () => {
if (input.files) {
Array.from(input.files).forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
}
input.click()
}
const showAll = () => {
searchInput.value = ''
modSearchInput.value = ''
filterMethod.value = 'all'
}
const filterMethodLabel = computed(() => {
switch (filterMethod.value) {
case 'disabled':
return 'Only disabled'
case 'enabled':
return 'Only enabled'
default:
return `All ${type.value.toLocaleLowerCase()}s`
}
})
const totalHeight = computed(() => {
const itemsHeight = filteredMods.value.length * ITEM_HEIGHT
return itemsHeight
})
const getVisibleRange = () => {
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const scrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(scrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(filteredMods.value.length, start + visibleCount + BUFFER_SIZE * 2),
}
}
const visibleTop = computed(() => {
const range = getVisibleRange()
return range.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
const range = getVisibleRange()
const items = filteredMods.value
return {
items: items.slice(Math.max(0, range.start), Math.min(items.length, range.end)),
}
})
const handleScroll = () => {
windowScrollY.value = window.scrollY
}
const handleResize = () => {
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
watch(
() => props.server.content?.data,
(newMods) => {
if (newMods) {
localMods.value = [...newMods]
}
},
{ immediate: true },
)
const debounce = <T extends (...args: any[]) => void>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout>
return function (...args: Parameters<T>): void {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
const pyroContentSentinel = ref<HTMLElement | null>(null)
const debouncedSearch = debounce(() => {
modSearchInput.value = searchInput.value
if (pyroContentSentinel.value) {
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect()
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
pyroContentSentinel.value.scrollIntoView({
// behavior: "smooth",
block: 'start',
})
}
}
}, 300)
function friendlyModName(mod: ContentItem) {
if (mod.name) return mod.name
// remove .disabled if at the end of the filename
let cleanName = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
// remove everything after the last dot
const lastDotIndex = cleanName.lastIndexOf('.')
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
return cleanName
}
function getStableModKey(mod: ContentItem): string {
if (mod.project_id) {
return `project-${mod.project_id}`
}
// external file
const baseFilename = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
return `file-${baseFilename}`
}
async function toggleMod(mod: ContentItem) {
mod.changing = true
const originalFilename = mod.filename
try {
const newFilename = mod.filename.endsWith('.disabled')
? mod.filename.slice(0, -9)
: `${mod.filename}.disabled`
const folder = `${type.value.toLocaleLowerCase()}s`
const sourcePath = `/${folder}/${mod.filename}`
const destinationPath = `/${folder}/${newFilename}`
mod.disabled = newFilename.endsWith('.disabled')
mod.filename = newFilename
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
await props.server.refresh(['general', 'content'])
} catch (error) {
mod.filename = originalFilename
mod.disabled = originalFilename.endsWith('.disabled')
console.error('Error toggling mod:', error)
addNotification({
text: `Something went wrong toggling ${friendlyModName(mod)}`,
type: 'error',
})
}
mod.changing = false
}
async function removeMod(mod: ContentItem) {
mod.changing = true
try {
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
console.error('Error removing mod:', error)
addNotification({
text: `couldn't remove ${mod.name || mod.filename}`,
type: 'error',
})
}
mod.changing = false
}
const hasMods = computed(() => {
return localMods.value?.length > 0
})
const hasFilteredMods = computed(() => {
return filteredMods.value?.length > 0
})
const filteredMods = computed(() => {
const mods = modSearchInput.value.trim()
? localMods.value.filter(
(mod) =>
mod.name?.toLowerCase().includes(modSearchInput.value.toLowerCase()) ||
mod.filename.toLowerCase().includes(modSearchInput.value.toLowerCase()),
)
: localMods.value
const statusFilteredMods = (() => {
switch (filterMethod.value) {
case 'disabled':
return mods.filter((mod) => mod.disabled)
case 'enabled':
return mods.filter((mod) => !mod.disabled)
default:
return mods
}
})()
return statusFilteredMods.sort((a, b) => {
return friendlyModName(a).localeCompare(friendlyModName(b))
})
})
</script>
<style scoped>
.sentinel {
position: absolute;
top: -1rem;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
</style>

View File

@@ -1,78 +1,58 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Your server is still accessible during this time.
</Admonition>
<Admonition
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
type="critical"
:header="`${serverData?.name} shut down unexpectedly.`"
dismissible
@dismiss="clearError"
>
<div class="flex w-full justify-between gap-2">
<div v-if="inspectingError.analysis.problems.length" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">
{{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
and found the following problems:
</div>
<li
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="list-none"
>
<h4 class="m-0 text-sm font-normal sm:text-lg sm:font-semibold">
{{ problem.message }}
</h4>
<ul class="m-0 ml-6">
<li v-for="solution in problem.solutions" :key="solution.message">
<span class="m-0 text-sm font-normal">{{ solution.message }}</span>
</li>
</ul>
</li>
<template v-if="inspectingError.analysis.problems.length">
<p class="m-0 text-sm opacity-80">
We automatically analyzed the logs and found the following:
</p>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="bg-raised-bg/30 rounded-xl px-3 py-2"
>
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
<li
v-for="solution in problem.solutions"
:key="solution.message"
class="text-sm opacity-80"
>
{{ solution.message }}
</li>
</ul>
</div>
</div>
<div v-else-if="props.serverPowerState === 'crashed'" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused
by a mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
We could not automatically determine the specific cause of the crash, but your
server exited with code
{{ props.powerStateDetails.exit_code }}.
{{
props.powerStateDetails.exit_code === 1
? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
: ''
}}
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<div class="mt-2">You can try restarting the server.</div>
</div>
</div>
</div>
<div v-else class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
We could not find any specific problems, but you can try restarting the server.
</div>
</div>
</div>
<ButtonStyled color="red" @click="clearError">
<button>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
<template v-else-if="props.serverPowerState === 'crashed'">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused by a
mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
Your server exited with code {{ props.powerStateDetails.exit_code }}.
<template v-if="props.powerStateDetails.exit_code === 1">
There may be a mod or plugin causing the issue, or an issue with your server
configuration.
</template>
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<p class="m-0 mt-2">You can try restarting the server.</p>
</template>
<template v-else>
We could not find any specific problems, but you can try restarting the server.
</template>
</Admonition>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
@@ -181,14 +161,18 @@
</template>
<script setup lang="ts">
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
import { TerminalSquareIcon } from '@modrinth/assets'
import {
Admonition,
injectModrinthClient,
injectModrinthServerContext,
useVIntl,
} from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
type ServerProps = {
isConnected: boolean
@@ -200,13 +184,22 @@ type ServerProps = {
exit_code?: number
}
isServerRunning: boolean
server: ModrinthServer
}
const props = defineProps<ServerProps>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const serverId = props.server.serverId
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
interface ErrorData {
id: string
@@ -581,7 +574,6 @@ const commandInput = ref('')
const suggestions = ref<string[]>([])
const selectedSuggestionIndex = ref(0)
const serverData = computed(() => props.server.general)
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");

View File

@@ -1,10 +1,10 @@
<template>
<ServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
<div class="flex flex-col gap-4">
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Some options may not be editable while the operation is in progress.
</Admonition>
<ServerSidebar :route="route" :nav-links="navLinks" />
</div>
</template>
<script setup lang="ts">
import {
@@ -18,26 +18,32 @@ import {
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, injectModrinthServerContext, useVIntl } from '@modrinth/ui'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const route = useRoute()
const serverId = route.params.id as string
const auth = await useAuth()
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const { formatMessage } = useVIntl()
const { server, busyReasons } = injectModrinthServerContext()
useHead({
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
const ownerId = computed(() => props.server.general?.owner_id ?? 'Ghost')
useHead({
title: `Options - ${server.value?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
@@ -46,7 +52,12 @@ const navLinks = computed(() => [
{ icon: WrenchIcon, label: 'Platform', href: `/hosting/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: 'Startup', href: `/hosting/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: 'Network', href: `/hosting/manage/${serverId}/options/network` },
{ icon: ListIcon, label: 'Properties', href: `/hosting/manage/${serverId}/options/properties` },
{
icon: ListIcon,
label: 'Properties',
href: `/hosting/manage/${serverId}/options/properties`,
shown: server.value?.status !== 'installing',
},
{
icon: UserIcon,
label: 'Preferences',

View File

@@ -105,8 +105,8 @@
<div v-else />
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
:save="saveGeneral"
:reset="resetGeneral"
/>
@@ -117,29 +117,28 @@
import { EditIcon, TransferIcon } from '@modrinth/assets'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
ServerIcon,
StyledInput,
} from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
import { useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { server, serverId, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const data = server
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
const icon = computed(() => data.value?.image)
const icon = useState<string | undefined>(`server-icon-${serverId}`)
const isUpdating = ref(false)
const hasUnsavedChanges = computed(
@@ -161,14 +160,14 @@ const saveGeneral = async () => {
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? '')
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
const result = await client.archon.servers_v0.checkSubdomainAvailability(
serverSubdomain.value,
)
const available = result.available
if (!available) {
addNotification({
@@ -179,7 +178,7 @@ const saveGeneral = async () => {
return
}
await props.server.network?.changeSubdomain(serverSubdomain.value)
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
} catch (error) {
console.error('Error checking subdomain availability:', error)
addNotification({
@@ -191,7 +190,7 @@ const saveGeneral = async () => {
}
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',
@@ -247,7 +246,7 @@ const uploadFile = async (e: Event) => {
})
try {
if (data.value?.image) {
if (icon.value) {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
}
@@ -264,8 +263,7 @@ const uploadFile = async (e: Event) => {
canvas.height = 512
ctx?.drawImage(img, 0, 0, 512, 512)
const dataURL = canvas.toDataURL('image/png')
useState(`server-icon-${props.server.serverId}`).value = dataURL
if (data.value) data.value.image = dataURL
useState(`server-icon-${serverId}`).value = dataURL
resolve()
URL.revokeObjectURL(img.src)
}
@@ -288,15 +286,14 @@ const uploadFile = async (e: Event) => {
}
const resetIcon = async () => {
if (data.value?.image) {
if (icon.value) {
try {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined
useState(`server-icon-${serverId}`).value = undefined
await props.server.refresh(['general'])
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',

View File

@@ -119,16 +119,15 @@
<script setup lang="ts">
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager } from '@modrinth/ui'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import {
ButtonStyled,
CopyCode,
injectModrinthServerContext,
injectNotificationManager,
} from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const { server: data, serverId } = injectModrinthServerContext()
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`)
@@ -146,7 +145,7 @@ const copyToClipboard = (name: string, textToCopy?: string) => {
}
const properties = [
{ name: 'Server ID', value: props.server.serverId ?? 'Unknown' },
{ name: 'Server ID', value: serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },

View File

@@ -1,22 +1,593 @@
<template>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
<div class="flex flex-col gap-6 rounded-2xl bg-surface-3 p-6">
<InstallationSettingsLayout ref="installationSettingsLayout">
<template #extra>
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.resetServerTitle)
}}</span>
<span class="text-primary">
{{ formatMessage(messages.resetServerDescription) }}
</span>
<div>
<ButtonStyled color="red">
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.resetServerButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<template #extra-modals>
<ServerSetupModal
ref="setupModal"
@reinstall="onReinstall"
@browse-modpacks="onBrowseModpacks"
/>
</template>
</InstallationSettingsLayout>
</div>
</template>
<script setup lang="ts">
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import type { Archon, LauncherMeta } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectTags,
InstallationSettingsLayout,
provideInstallationSettings,
ServerSetupModal,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const debug = useDebugLogger('LoaderPage')
const client = injectModrinthClient()
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const tags = injectTags()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resetServerTitle: {
id: 'hosting.loader.reset-server',
defaultMessage: 'Reset server',
},
resetServerDescription: {
id: 'hosting.loader.reset-server-description',
defaultMessage:
'Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored.',
},
loaderVersionLabel: {
id: 'hosting.loader.loader-version',
defaultMessage: '{loader, select, null {Loader} other {{loader}}} version',
},
failedToLoadVersions: {
id: 'hosting.loader.failed-to-load-versions',
defaultMessage: 'Failed to load versions',
},
failedToChangeVersion: {
id: 'hosting.loader.failed-to-change-version',
defaultMessage: 'Failed to change modpack version',
},
failedToSaveSettings: {
id: 'hosting.loader.failed-to-save-settings',
defaultMessage: 'Failed to save installation settings',
},
repairStartedTitle: {
id: 'hosting.loader.repair-started-title',
defaultMessage: 'Repair completed',
},
repairStartedText: {
id: 'hosting.loader.repair-started-text',
defaultMessage: 'Your server installation has been repaired.',
},
failedToRepair: {
id: 'hosting.loader.failed-to-repair',
defaultMessage: 'Failed to repair server',
},
failedToReinstall: {
id: 'hosting.loader.failed-to-reinstall',
defaultMessage: 'Failed to reinstall modpack',
},
failedToUnlink: {
id: 'hosting.loader.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
})
const emit = defineEmits<{
reinstall: [any?]
'reinstall-failed': []
}>()
const isInstalling = computed(() => {
const val =
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
debug(
'isInstalling:',
val,
'server.status:',
server.value?.status,
'isSyncingContent:',
isSyncingContent.value,
)
return val
})
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
async function invalidateServerState() {
debug('invalidateServerState: starting')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
])
debug('invalidateServerState: complete')
}
const addonsQuery = useQuery({
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
})
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
const modpackVersionsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpack.value?.spec.project_id]),
queryFn: () =>
client.labrinth.versions_v2.getProjectVersions(modpack.value!.spec.project_id, {
include_changelog: false,
}),
enabled: computed(() => !!modpack.value?.spec.project_id),
})
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
function toApiLoaderName(loader: string): string {
return loader === 'neoforge' ? 'neo' : loader
}
const apiLoaderName = computed(() =>
modLoaders.includes(editingPlatform.value) ? toApiLoaderName(editingPlatform.value) : null,
)
const manifestQuery = useQuery({
queryKey: computed(() => ['loader-manifest', apiLoaderName.value] as const),
queryFn: () => client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!),
enabled: computed(() => !!apiLoaderName.value),
staleTime: 5 * 60 * 1000,
})
const paperBuildsQuery = useQuery({
queryKey: computed(() => ['paper-builds', editingGameVersion.value] as const),
queryFn: () => client.paper.versions_v3.getBuilds(editingGameVersion.value),
enabled: computed(() => editingPlatform.value === 'paper' && !!editingGameVersion.value),
staleTime: 5 * 60 * 1000,
})
const purpurBuildsQuery = useQuery({
queryKey: computed(() => ['purpur-builds', editingGameVersion.value] as const),
queryFn: () => client.purpur.versions_v2.getBuilds(editingGameVersion.value),
enabled: computed(() => editingPlatform.value === 'purpur' && !!editingGameVersion.value),
staleTime: 5 * 60 * 1000,
})
type LoaderVersionEntry = LauncherMeta.Manifest.v0.LoaderVersion
function getLoaderVersionsForGameVersion(
loader: string,
gameVersion: string,
): LoaderVersionEntry[] {
if (loader === 'paper') {
return (paperBuildsQuery.data.value?.builds ?? [])
.toSorted((a, b) => b - a)
.map((b) => ({ id: String(b), stable: true }))
}
if (loader === 'purpur') {
return (purpurBuildsQuery.data.value?.builds.all ?? [])
.toSorted((a, b) => parseInt(b) - parseInt(a))
.map((b) => ({ id: b, stable: true }))
}
const manifest = manifestQuery.data.value?.gameVersions
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)
return entry?.loaders ?? []
}
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
return loader as Archon.Content.v1.Modloader
}
provideInstallationSettings({
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
const addons = addonsQuery.data.value
const rawLoader = addons?.modloader ?? server.value?.loader ?? null
const loader = rawLoader ? formatLoaderLabel(rawLoader) : null
const gameVersion = addons?.game_version ?? server.value?.mc_version ?? null
const loaderVersion = addons?.modloader_version ?? server.value?.loader_version ?? null
debug('installationInfo computed:', {
'addons?.modloader': addons?.modloader,
'server.loader': server.value?.loader,
rawLoader,
loader,
'addons?.game_version': addons?.game_version,
'server.mc_version': server.value?.mc_version,
gameVersion,
'addons?.modloader_version': addons?.modloader_version,
'server.loader_version': server.value?.loader_version,
loaderVersion,
'addonsQuery.isLoading': addonsQuery.isLoading.value,
'addonsQuery.isFetching': addonsQuery.isFetching.value,
})
const rows = [
{ label: formatMessage(commonMessages.platformLabel), value: loader },
{ label: formatMessage(commonMessages.gameVersionLabel), value: gameVersion },
]
if (loader !== 'Vanilla') {
rows.push({
label: formatMessage(messages.loaderVersionLabel, { loader: loader ?? 'null' }),
value: loaderVersion,
})
}
return rows
}),
isLinked: computed(() => {
const val = !!modpack.value
debug('isLinked:', val, 'modpack:', modpack.value?.spec?.project_id)
return val
}),
isBusy: isInstalling,
modpack: computed(() => {
if (!modpack.value) return null
return {
iconUrl: modpack.value.icon_url,
title: modpack.value.title ?? modpack.value.spec.project_id,
link: `/project/${modpack.value.spec.project_id}`,
versionNumber: modpack.value.version_number,
owner: modpack.value.owner
? {
id: modpack.value.owner.id,
name: modpack.value.owner.name,
iconUrl: modpack.value.owner.icon_url,
type: modpack.value.owner.type as 'user' | 'organization',
}
: undefined,
}
}),
currentPlatform: computed(() => server.value?.loader?.toLowerCase() ?? 'vanilla'),
currentGameVersion: computed(() => server.value?.mc_version ?? ''),
currentLoaderVersion: computed(() => server.value?.loader_version ?? ''),
availablePlatforms: ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur'],
editingPlatformRef: editingPlatform,
editingGameVersionRef: editingGameVersion,
resolveGameVersions(loader, showSnapshots) {
const versions = showSnapshots
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
if (loader && loader !== 'vanilla' && !['paper', 'purpur'].includes(loader)) {
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).map((x) => x.id),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
}
}
return versions.map((v) => ({ value: v.version, label: v.version }))
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
return getLoaderVersionsForGameVersion(loader, gameVersion)
},
resolveHasSnapshots(loader) {
if (loader === 'vanilla' || ['paper', 'purpur'].includes(loader)) {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
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).map((x) => x.id))
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
return supported.some((v) => v.version_type !== 'release')
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called with', { platform, gameVersion, loaderVersionId })
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged
? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
if (platformChanged) {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: loaderVersionId ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
debug('save: platform changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
debug('save: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('save: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
})
throw err
}
},
async repair() {
debug('repair: called')
try {
await client.archon.content_v1.repair(serverId, worldId.value!)
debug('repair: API succeeded, invalidating')
await invalidateServerState()
addNotification({
type: 'success',
title: formatMessage(messages.repairStartedTitle),
text: formatMessage(messages.repairStartedText),
})
} catch (err) {
debug('repair: failed', err)
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
})
}
},
async reinstallModpack() {
if (!modpack.value) return
debug(
'reinstallModpack: called, project:',
modpack.value.spec.project_id,
'version:',
modpack.value.spec.version_id,
)
debug('reinstallModpack: emitting reinstall before API call')
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: modpack.value.spec.project_id,
version_id: modpack.value.spec.version_id,
},
soft_override: false,
})
debug('reinstallModpack: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('reinstallModpack: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
})
}
},
async unlinkModpack() {
debug('unlinkModpack: called')
const previousData = addonsQuery.data.value
if (previousData) {
debug('unlinkModpack: optimistically removing modpack from cache')
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
...previousData,
modpack: null,
})
}
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
debug('unlinkModpack: API succeeded')
} catch (err) {
debug('unlinkModpack: failed, reverting cache', err)
if (previousData) {
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
}
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
})
} finally {
debug('unlinkModpack: invalidating queries')
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['servers', 'detail', serverId],
}),
queryClient.invalidateQueries({
queryKey: ['content', 'list', 'v1', serverId],
}),
])
debug('unlinkModpack: invalidation complete')
}
},
getCachedModpackVersions: () => modpackVersionsQuery.data.value ?? null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called, project:', modpack.value?.spec.project_id)
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(
modpack.value!.spec.project_id,
{
include_changelog: false,
},
)
debug('fetchModpackVersions: got', versions.length, 'versions')
return versions
} catch (err) {
debug('fetchModpackVersions: failed', err)
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
throw err
}
},
async getVersionChangelog(versionId) {
debug('getVersionChangelog: called, versionId:', versionId)
try {
return await client.labrinth.versions_v2.getVersion(versionId)
} catch {
debug('getVersionChangelog: failed for', versionId)
return null
}
},
async onModpackVersionConfirm(version) {
if (!modpack.value) return
debug('onModpackVersionConfirm: called, version:', version.id)
debug('onModpackVersionConfirm: emitting reinstall before API call')
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: modpack.value.spec.project_id,
version_id: version.id,
},
soft_override: true,
})
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToChangeVersion),
})
}
},
updaterModalProps: computed(() => ({
isApp: false,
currentVersionId: modpack.value?.spec.version_id ?? '',
projectIconUrl: modpack.value?.icon_url ?? undefined,
projectName:
modpack.value?.title ??
modpack.value?.spec.project_id ??
formatMessage(commonMessages.modpackLabel),
currentGameVersion: addonsQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
currentLoader: addonsQuery.data.value?.modloader ?? server.value?.loader ?? '',
})),
isServer: true,
isApp: false,
lockPlatform: true,
hideLoaderVersion: true,
async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
const result = await client.archon.content_v1.getUpdateGameVersionPreview(
serverId,
worldId.value!,
gameVersion,
signal,
)
if (result.addon_changes.length === 0 && !result.has_unknown_content) return null
return {
diffs: result.addon_changes.map((diff) => ({
type: diff.type,
projectName: diff.project?.title ?? undefined,
fileName: diff.file_name ?? undefined,
currentVersionName: diff.current_version?.version_number ?? undefined,
newVersionName: diff.new_version?.version_number ?? undefined,
})),
newGameVersion: result.new_game_version,
newLoaderVersion: result.new_loader_version,
hasUnknownContent: result.has_unknown_content,
}
},
})
watch(
() => server.value?.status,
(newStatus, oldStatus) => {
debug('status watcher:', oldStatus, '->', newStatus, {
'server.loader': server.value?.loader,
'server.mc_version': server.value?.mc_version,
'server.loader_version': server.value?.loader_version,
})
if (oldStatus === 'installing' && newStatus === 'available') {
debug('status installing->available, resetting editing refs')
editingPlatform.value = server.value?.loader?.toLowerCase() ?? 'vanilla'
editingGameVersion.value = server.value?.mc_version ?? ''
}
},
)
function onReinstall(event?: any) {
installationSettingsLayout.value?.cancelEditing()
emit('reinstall', event)
}
function onBrowseModpacks() {
debug('onBrowseModpacks: navigating to modpack discovery')
navigateTo({
path: '/discover/modpacks',
query: { sid: serverId, from: 'reset-server', wid: worldId.value },
})
}
</script>

View File

@@ -58,7 +58,7 @@
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
v-if="allocationsError"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -72,10 +72,10 @@
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.network.error)
allocationsError?.message ?? 'Unknown error'
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
@@ -249,7 +249,7 @@
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:server-id="serverId"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
@@ -273,22 +273,24 @@ import {
ButtonStyled,
ConfirmModal,
CopyCode,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
StyledInput,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const isUpdating = ref(false)
const data = computed(() => props.server.general)
const data = server
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
@@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
const network = computed(() => props.server.network)
const allocations = computed(() => network.value?.allocations)
const {
data: allocationsData,
error: allocationsError,
refetch: refetchAllocations,
} = useQuery({
queryKey: ['servers', 'allocations', serverId] as const,
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
})
const allocations = allocationsData
const newAllocationModal = ref<typeof NewModal>()
const editAllocationModal = ref<typeof NewModal>()
@@ -316,8 +325,8 @@ const addNewAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.reserveAllocation(newAllocationName.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
newAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => {
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
await props.server.network?.deleteAllocation(allocationToDelete.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
@@ -376,8 +385,12 @@ const editAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.updateAllocation(
serverId,
newAllocationPort.value,
newAllocationName.value,
)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
editAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -397,7 +410,8 @@ const saveNetwork = async () => {
try {
isUpdating.value = true
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
const available = result.available
if (!available) {
addNotification({
type: 'error',
@@ -407,13 +421,18 @@ const saveNetwork = async () => {
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value)
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
await client.archon.servers_v0.updateAllocation(
serverId,
serverPrimaryPort.value,
newAllocationName.value,
)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',

View File

@@ -32,7 +32,7 @@
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:server-id="serverId"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
@@ -45,16 +45,11 @@ import { injectNotificationManager, Toggle } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const route = useNativeRoute()
const serverId = route.params.id as string
const props = defineProps<{
server: ModrinthServer
}>()
const preferences = {
ramAsNumber: {
displayName: 'RAM as bytes',

View File

@@ -1,9 +1,11 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4 overflow-y-auto">
<Admonition
v-if="missingKnownProperties.length > 0"
type="warning"
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
/>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
@@ -22,7 +24,7 @@
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
@@ -35,303 +37,258 @@
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
v-for="(_value, key) in filteredProperties"
:key="key"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<span :id="`property-label-${key}`">{{ formatPropertyName(key) }}</span>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
v-if="getPropertyDef(key).type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<Combobox
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="(overrides[index].options || []).map((v) => ({ value: v, label: v }))"
:aria-labelledby="`property-label-${index}`"
:display-value="String(liveProperties[index] ?? 'Select...')"
:id="`server-property-${key}`"
v-model="liveProperties[key]"
:name="formatPropertyName(key)"
:options="
(getPropertyDef(key) as DropdownPropertyDef).options.map((v) => ({
value: v,
label: formatPropertyName(v),
}))
"
:aria-labelledby="`property-label-${key}`"
:display-value="formatPropertyName(String(liveProperties[key] ?? 'Select...'))"
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<div v-else-if="getPropertyDef(key).type === 'toggle'" class="flex justify-end">
<Toggle
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:aria-labelledby="`property-label-${index}`"
:id="`server-property-${key}`"
:model-value="liveProperties[key] === 'true'"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
/>
</div>
<div
v-else-if="typeof property === 'number' && index !== 'level-seed' && index !== 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<div v-else-if="getPropertyDef(key).type === 'number'" class="mt-2 w-full sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
/>
</div>
<div
v-else-if="index === 'level-seed' || index === 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
v-model="liveProperties[index]"
multiline
resize="vertical"
input-class="p-2"
:aria-labelledby="`property-label-${index}`"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
:id="`server-property-${key}`"
v-model="liveProperties[key]"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
:aria-labelledby="`property-label-${key}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
<div v-else class="flex h-full w-full items-center justify-center">
<SpinnerIcon class="animate-spin" />
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:save="saveProperties"
:save="() => saveProperties()"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, SearchIcon } from '@modrinth/assets'
import type { Archon } from '@modrinth/api-client'
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
import {
Admonition,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
const tags = useGeneratedState()
const isUpdating = ref(false)
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const searchInput = ref('')
const data = computed(() => props.server.general)
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
await modulesLoaded
try {
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
const rawProps = await blob.text()
if (!rawProps) return null
type DropdownPropertyDef = { type: 'dropdown'; options: string[] }
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' } | DropdownPropertyDef
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
allow_cheats: { type: 'toggle' },
allow_flight: { type: 'toggle' },
difficulty: { type: 'dropdown', options: ['peaceful', 'easy', 'normal', 'hard'] },
enforce_whitelist: { type: 'toggle' },
force_gamemode: { type: 'toggle' },
gamemode: { type: 'dropdown', options: ['survival', 'creative', 'adventure', 'spectator'] },
generate_structures: { type: 'toggle' },
generator_settings: { type: 'text' },
hardcore: { type: 'toggle' },
level_seed: { type: 'text' },
level_type: { type: 'text' },
max_players: { type: 'number' },
max_tick_time: { type: 'number' },
motd: { type: 'text' },
pause_when_empty_seconds: { type: 'number' },
player_idle_timeout: { type: 'number' },
require_resource_pack: { type: 'toggle' },
resource_pack: { type: 'text' },
resource_pack_id: { type: 'text' },
resource_pack_sha1: { type: 'text' },
simulation_distance: { type: 'number' },
spawn_protection: { type: 'number' },
sync_chunk_writes: { type: 'toggle' },
view_distance: { type: 'number' },
white_list: { type: 'toggle' },
}
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
const rawValue = valueParts.join('=')
let value: string | boolean | number = rawValue
function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
value = rawValue.toLowerCase() === 'true'
} else {
const intLike = /^[-+]?\d+$/.test(rawValue)
if (intLike) {
const n = Number(rawValue)
if (Number.isSafeInteger(n)) {
value = n
}
}
}
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
properties[key.trim()] = value
}
return properties
} catch {
return null
}
const { data: propsData } = useQuery({
queryKey,
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const liveProperties = ref<Record<string, any>>({})
const originalProperties = ref<Record<string, any>>({})
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
const result: Record<string, string> = {}
if (data.known) {
for (const [key, value] of Object.entries(data.known)) {
if (value != null) result[key] = value
}
}
if (data.custom) {
for (const [key, value] of Object.entries(data.custom)) {
if (value != null) result[key] = value
}
}
return result
}
const liveProperties = ref<Record<string, string>>({})
const originalProperties = ref<Record<string, string>>({})
function syncFormFromData() {
if (!propsData.value) return
const flat = flattenProperties(propsData.value)
liveProperties.value = { ...flat }
originalProperties.value = { ...flat }
}
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
console.log(newPropsData)
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
(newData, oldData) => {
if (newData && !oldData) {
syncFormFromData()
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
)
watch(powerState, () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
})
const getDifficultyOptions = () => {
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split('.').map(Number)
return versionNumbers[0] === 1 && versionNumbers[1] < 13
})
.map((v) => v.version)
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ['0', '1', '2', '3']
} else {
return ['peaceful', 'easy', 'normal', 'hard']
}
}
const missingKnownProperties = computed(() =>
Object.keys(KNOWN_PROPERTIES).filter((key) => !(key in liveProperties.value)),
)
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: 'dropdown',
options: getDifficultyOptions(),
},
gamemode: {
type: 'dropdown',
options: ['survival', 'creative', 'adventure', 'spectator'],
},
}
const hasUnsavedChanges = computed(() =>
Object.keys(liveProperties.value).some(
(key) => liveProperties.value[key] !== originalProperties.value[key],
),
)
const fuse = computed(() => {
if (!liveProperties.value) return null
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
const known: Record<string, string> = {}
const custom: Record<string, string> = {}
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(propertiesToFuse, {
keys: ['key', 'value'],
threshold: 0.2,
})
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) {
return liveProperties.value
}
const results = fuse.value?.search(searchInput.value) ?? []
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
const constructServerProperties = (): string => {
const properties = liveProperties.value
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'object') {
fileContent += `${key}=${JSON.stringify(value)}\n`
} else if (typeof value === 'boolean') {
fileContent += `${key}=${value ? 'true' : 'false'}\n`
for (const key of Object.keys(liveProperties.value)) {
if (liveProperties.value[key] === originalProperties.value[key]) continue
if (key in KNOWN_PROPERTIES) {
known[key] = liveProperties.value[key]
} else {
fileContent += `${key}=${value}\n`
custom[key] = liveProperties.value[key]
}
}
return fileContent
const patch: Archon.Content.v1.PatchPropertiesFields = {}
if (Object.keys(known).length > 0) {
patch.known = known as Archon.Content.v1.KnownPropertiesFields
}
if (Object.keys(custom).length > 0) {
patch.custom = custom
}
return patch
}
const saveProperties = async () => {
try {
isUpdating.value = true
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()
const { mutate: saveProperties, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
} catch (error) {
console.error('Error updating server properties:', error)
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: 'An error occurred while attempting to update your server properties.',
text: error instanceof Error ? error.message : 'An error occurred.',
})
} finally {
isUpdating.value = false
}
},
})
function resetProperties() {
syncFormFromData()
}
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
await new Promise((resolve) => setTimeout(resolve, 200))
}
const fuse = computed(() => {
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
})
const formatPropertyName = (propertyName: string): string => {
return propertyName
.split(/[-.]/)
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) return liveProperties.value
const results = fuse.value.search(searchInput.value)
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
function formatPropertyName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const isComplexProperty = (property: any): boolean => {
return (
typeof property === 'object' ||
(typeof property === 'string' &&
(property.includes(',') ||
property.includes('{') ||
property.includes('}') ||
property.includes('[') ||
property.includes(']') ||
property.length > 30))
)
}
</script>

View File

@@ -1,32 +1,6 @@
<template>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
@@ -42,7 +16,7 @@
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -51,13 +25,22 @@
</button>
</ButtonStyled>
</div>
<StyledInput
id="startup-command-field"
v-model="invocation"
multiline
resize="vertical"
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
/>
<div class="relative">
<StyledInput
id="startup-command-field"
v-model="startupCommand"
multiline
resize="vertical"
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
</div>
</div>
</div>
<div class="card flex flex-col gap-8">
@@ -70,168 +53,203 @@
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">
<Toggle id="show-all-versions" v-model="showAllVersions" class="flex-none" />
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
<div class="relative max-w-xs">
<Combobox
:id="'java-version-field'"
v-model="javaVersion"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:disabled="isStartupLoading"
>
<template #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="showAllVersions = !showAllVersions"
>
<EyeOffIcon v-if="showAllVersions" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
</button>
</template>
</Combobox>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
<Combobox
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions.map((v) => ({ value: v, label: v }))"
:display-value="jdkVersion ?? 'Java Version'"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<Combobox
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM'].map((v) => ({ value: v, label: v }))"
:display-value="jdkBuild ?? 'Runtime'"
/>
<div class="relative max-w-xs">
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
</div>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import type { Archon } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
await props.server.startup.fetch()
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
const data = computed(() => props.server.general)
const showAllVersions = ref(false)
const { data: startupData, isLoading: isStartupLoading } = useQuery({
queryKey: startupQueryKey,
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const jdkVersionMap = [
{ value: 'lts8', label: 'Java 8' },
{ value: 'lts11', label: 'Java 11' },
{ value: 'lts17', label: 'Java 17' },
{ value: 'lts21', label: 'Java 21' },
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
{ value: 21, label: 'Java 21' },
]
const jdkBuildMap = [
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
const invocation = ref(props.server.startup.invocation)
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
// Saved state derived directly from query
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
const defaultStartupCommand = computed(
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
)
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label)
const originalInvocation = ref(invocation.value)
const originalJdkVersion = ref(jdkVersion.value)
const originalJdkBuild = ref(jdkBuild.value)
// Local form state
const startupCommand = ref('')
const javaVersion = ref<number>()
const jreVendor = ref<Archon.Content.v1.JreVendor>()
// Display labels for comboboxes
const javaVersionLabel = computed(
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
)
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
function syncFormFromData() {
startupCommand.value = savedStartupCommand.value
javaVersion.value = savedJavaVersion.value
jreVendor.value = savedJreVendor.value
}
watch(
startupData,
(newData, oldData) => {
if (newData && !oldData) {
syncFormFromData()
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
startupCommand.value !== savedStartupCommand.value ||
javaVersion.value !== savedJavaVersion.value ||
jreVendor.value !== savedJreVendor.value,
)
const isUpdating = ref(false)
const compatibleJavaVersions = computed(() => {
const mcVersion = data.value?.mc_version ?? ''
if (!mcVersion) return jdkVersionMap.map((v) => v.label)
const [major, minor] = mcVersion.split('.').map(Number)
if (major >= 1) {
if (minor >= 20) return ['Java 21']
if (minor >= 18) return ['Java 17', 'Java 21']
if (minor >= 17) return ['Java 16', 'Java 17', 'Java 21']
if (minor >= 12) return ['Java 8', 'Java 11', 'Java 17', 'Java 21']
if (minor >= 6) return ['Java 8', 'Java 11']
}
return ['Java 8']
})
// Java version filtering
const showAllVersions = ref(false)
const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
if (showAllVersions.value) return JAVA_VERSIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
const [, minor] = mcVersion.split('.').map(Number)
if (minor >= 20) return JAVA_VERSIONS.filter((v) => v.value === 21)
if (minor >= 17) return JAVA_VERSIONS.filter((v) => [17, 21].includes(v.value))
if (minor >= 12) return JAVA_VERSIONS
if (minor >= 6) return JAVA_VERSIONS.filter((v) => [8, 11].includes(v.value))
return JAVA_VERSIONS.filter((v) => v.value === 8)
})
async function saveStartup() {
try {
isUpdating.value = true
const invocationValue = invocation.value ?? ''
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any)
await new Promise((resolve) => setTimeout(resolve, 10))
await props.server.refresh(['startup'])
if (props.server.startup) {
invocation.value = props.server.startup.invocation
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || ''
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || ''
originalInvocation.value = invocation.value
originalJdkVersion.value = jdkVersion.value
originalJdkBuild.value = jdkBuild.value
}
// Save mutation
const { mutate: saveStartup, isPending } = useMutation({
mutationFn: () =>
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
startup_command: startupCommand.value || null,
java_version: javaVersion.value ?? null,
jre_vendor: jreVendor.value ?? null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
})
} finally {
isUpdating.value = false
}
}
},
})
function resetStartup() {
invocation.value = originalInvocation.value
jdkVersion.value = originalJdkVersion.value
jdkBuild.value = originalJdkBuild.value
syncFormFromData()
}
function resetToDefault() {
invocation.value = originalInvocation.value ?? ''
startupCommand.value = defaultStartupCommand.value
}
</script>