feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -0,0 +1,3 @@
export * from './pages'
export * from './providers'
export * from './tabs'

View File

@@ -0,0 +1,391 @@
<template>
<div class="relative h-full w-full">
<div class="flex h-full w-full flex-col gap-4">
<div class="flex flex-col gap-6">
<!-- SFTP section -->
<div class="flex flex-col gap-2">
<div class="flex flex-col items-center justify-between gap-0.5 sm:flex-row">
<span class="text-lg font-semibold text-contrast">SFTP</span>
<ButtonStyled>
<a
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
:href="sftpUrl"
target="_blank"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</a>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
<span class="text-lg font-semibold text-contrast">Server Address</span>
<div
v-tooltip="'Copy SFTP server address'"
class="copy-field hover:bg-button-bg-hover"
@click="copyToClipboard('Server address', server?.sftp_host)"
>
<span class="cursor-pointer font-semibold text-primary">
{{ server?.sftp_host }}
</span>
<div class="grid h-10 w-10 place-content-center">
<CopyIcon class="h-5 w-5" />
</div>
</div>
<div class="flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Username</span>
<div
v-tooltip="'Copy SFTP username'"
class="copy-field hover:bg-button-bg-hover"
@click="copyToClipboard('Username', server?.sftp_username)"
>
<div class="truncate font-semibold">
{{ server?.sftp_username }}
</div>
<div class="grid h-10 w-9 place-content-center">
<CopyIcon class="h-5 w-5" />
</div>
</div>
</div>
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Password</span>
<div
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
@click="copyToClipboard('Password', server?.sftp_password)"
>
<div class="flex items-center gap-1.5 h-full w-full">
<div
v-tooltip="'Copy SFTP Password'"
class="h-full flex justify-between grow items-center"
>
<div class="truncate font-semibold">
{{
showPassword
? server?.sftp_password
: '*'.repeat(server?.sftp_password?.length ?? 0)
}}
</div>
<CopyIcon class="h-5 w-5" />
</div>
<ButtonStyled type="transparent" circular>
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
@click.stop="showPassword = !showPassword"
>
<!-- look into doing stop propagation here -->
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeOffIcon v-else class="h-5 w-5" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Startup command section -->
<div class="flex flex-col gap-2.5">
<div class="flex h-10 flex-col items-end justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="mb-0.5 flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Startup command</span>
</label>
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
<button
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
class="relative !w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Default
</button>
</ButtonStyled>
</div>
<div class="relative">
<StyledInput
id="startup-command-field"
v-model="startupCommand"
multiline
resize="vertical"
input-class="font-mono field-sizing-content"
: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>
<span> The command that runs when your server is started. </span>
</div>
<!-- Java version section -->
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Java version</span>
</div>
<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>
<span> The Java version your server runs on. </span>
</div>
<!-- Java runtime section -->
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Java runtime</span>
</div>
<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>
<span> The Java runtime your server will use. </span>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges || isPending"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CopyIcon,
ExternalIcon,
EyeIcon,
EyeOffIcon,
SpinnerIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
// SFTP state
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
const copyToClipboard = (name: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
title: `${name} copied to clipboard!`,
})
}
// Startup state
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
const { data: startupData, isLoading: isStartupLoading } = useQuery({
queryKey: startupQueryKey,
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
{ value: 21, label: 'Java 21' },
{ value: 25, label: 'Java 25' },
]
const showAllVersions = ref(false)
type MinecraftReleaseVersion = {
major: number
minor: number
}
function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion | null {
const [majorPart, minorPart] = version.split('.')
if (!majorPart || !minorPart) return null
const major = Number(majorPart)
const minor = Number(minorPart)
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
return { major, minor }
}
function filterJavaVersions(compatibleVersions: number[]) {
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
}
const displayedJavaVersions = computed(() => {
if (showAllVersions.value) return JAVA_VERSIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
if (!releaseVersion) return JAVA_VERSIONS
if (releaseVersion.major > 1) {
if (releaseVersion.major >= 26) {
return filterJavaVersions([25])
}
return JAVA_VERSIONS
}
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
if (releaseVersion.minor >= 17) return filterJavaVersions([17, 21])
if (releaseVersion.minor >= 12) return filterJavaVersions([8, 11, 17, 21])
if (releaseVersion.minor >= 6) return filterJavaVersions([8, 11])
return filterJavaVersions([8])
})
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
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 startupCommand = ref('')
const javaVersion = ref<number>()
const jreVendor = ref<Archon.Content.v1.JreVendor>()
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(
() =>
startupCommand.value !== savedStartupCommand.value ||
javaVersion.value !== savedJavaVersion.value ||
jreVendor.value !== savedJreVendor.value,
)
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.',
})
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
})
},
})
function resetStartup() {
syncFormFromData()
}
function resetToDefault() {
startupCommand.value = defaultStartupCommand.value
}
</script>
<style scoped>
.copy-field {
@apply flex h-10 cursor-pointer items-center justify-between gap-2 rounded-lg bg-button-bg px-3 pr-1.5 transition-all;
@apply hover:brightness-125 active:scale-95;
}
.copy-field-has-button {
@apply flex h-10 cursor-pointer items-center justify-between gap-2 rounded-lg bg-button-bg px-3 pr-1.5 transition-all;
@apply [&:hover:not(:has(button:hover))]:brightness-125 [&:active:not(:has(button:active))]:scale-95;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="relative h-full w-full">
<div v-if="data" class="flex h-full w-full flex-col">
<div class="flex flex-col gap-6">
<div class="flex justify-start gap-16">
<div class="flex max-w-[500px] grow flex-col gap-6">
<!-- Server name -->
<div class="flex flex-col gap-2.5">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Server name</span>
</label>
<div class="flex flex-col gap-2.5">
<StyledInput
id="server-name-field"
v-model="serverName"
wrapper-class="w-full"
:maxlength="48"
@keyup.enter="!serverName && saveGeneral"
/>
<span>This name is only visible on Modrinth.</span>
<div class="text-red font-medium">
<span v-if="!isValidServerName"> Server name cannot be empty. </span>
</div>
</div>
</div>
<!-- Hostname -->
<div class="flex flex-col gap-2.5">
<label for="server-subdomain" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Hostname</span>
<div
class="flex w-full overflow-hidden rounded-xl bg-button-bg px-3 [box-shadow:var(--shadow-inset-sm)] transition-[box-shadow] duration-100 ease-in-out focus-within:[box-shadow:0_0_0_0.25rem_var(--color-brand-shadow)]"
>
<div class="relative inline-flex min-h-9 items-center">
<span
class="pointer-events-none invisible whitespace-pre px-px text-base font-medium"
aria-hidden="true"
>{{ serverSubdomain || 'Enter subdomain...' }}</span
>
<input
id="server-subdomain"
:value="serverSubdomain"
placeholder="Enter subdomain..."
:maxlength="32"
class="absolute left-px inset-0 bg-transparent !p-0 text-base font-medium text-primary !shadow-none transition-colors placeholder:text-secondary focus:text-contrast"
autocomplete="off"
@input="serverSubdomain = ($event.target as HTMLInputElement).value"
@keyup.enter="saveGeneral"
/>
</div>
<div
class="flex min-h-9 shrink-0 select-none items-center py-2 pr-4 font-medium opacity-50 [filter:grayscale(50%)]"
:class="!serverSubdomain ? '!ml-auto' : ''"
>
.modrinth.gg
</div>
</div>
</label>
<span>Your friends can connect to your server using this address.</span>
<div v-if="!isValidSubdomain" class="text-red font-medium">
<span v-if="!isValidLengthSubdomain">
Subdomain must be at least 5 characters long.
</span>
<span v-if="!isValidCharsSubdomain">
Subdomain can only contain alphanumeric characters and dashes.
</span>
</div>
</div>
</div>
<EditServerIcon v-if="!data.is_medal" />
</div>
<!-- preferences -->
<div
v-for="(prefConfig, key) in preferences"
:key="key"
class="flex items-center justify-between gap-2"
>
<label :for="`pref-${key}`" class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<span class="text-lg font-semibold text-contrast">{{ prefConfig.displayName }}</span>
<div
v-if="!prefConfig.implemented"
class="hidden items-center gap-1 rounded-full bg-surface-2 p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
</div>
</div>
<span>{{ prefConfig.description }}</span>
</label>
<Toggle
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="flex-none"
:disabled="!prefConfig.implemented"
/>
</div>
<!-- Info -->
<div class="flex flex-col gap-2.5">
<div class="text-lg m-0 font-semibold text-contrast">Info</div>
<div class="flex flex-col gap-2.5 rounded-xl bg-surface-2 p-4">
<div
v-for="property in infoProperties"
:key="property.name"
class="flex items-center justify-between gap-4"
>
<template v-if="property.value !== 'Unknown'">
<span>{{ property.name }}</span>
<CopyCode :text="property.value" />
</template>
</div>
</div>
</div>
</div>
</div>
<div v-else />
<SaveBanner
:is-visible="(!!hasUnsavedChanges && !!isValidServerName) || isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { useQueryClient } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { CopyCode, StyledInput, Toggle } from '#ui/components'
import EditServerIcon from '#ui/components/servers/edit-server-icon/EditServerIcon.vue'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { server: data, serverId, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
watch(data, (newData) => {
if (newData) {
serverName.value = newData.name
serverSubdomain.value = newData.net?.domain ?? ''
}
})
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
const isValidCharsSubdomain = computed(
() => !serverSubdomain.value || /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value),
)
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
const isUpdating = ref(false)
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
watch(serverName, (newValue, oldValue) => {
if (!(newValue?.length ?? 0)) {
serverName.value = oldValue
}
})
// Preferences
const preferences = {
hideSubdomainLabel: {
displayName: 'Hide subdomain label',
description: 'When enabled, the subdomain label will be hidden from the server header.',
implemented: true,
},
// autoRestart: {
// displayName: 'Auto restarts',
// description: 'Automatically restart the server if it crashes.',
// implemented: false,
// },
ramAsNumber: {
displayName: 'RAM as bytes',
description: 'Show RAM usage in bytes instead of a percentage.',
implemented: true,
},
} as const
type PreferenceKeys = keyof typeof preferences
type UserPreferences = {
[K in PreferenceKeys]: boolean
}
const defaultPreferences: UserPreferences = {
hideSubdomainLabel: false,
// autoRestart: false,
ramAsNumber: false,
}
const userPreferences = useStorage<UserPreferences>(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
)
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
// Info properties
const infoProperties = [
{ name: 'Server ID', value: serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
]
// Unsaved changes tracking (API fields + preferences)
const hasUnsavedChanges = computed(
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain ||
JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value),
)
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
const result = await client.archon.servers_v0.checkSubdomainAvailability(
serverSubdomain.value,
)
const available = result.available
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
} catch (error) {
console.error('Error checking subdomain availability:', error)
addNotification({
type: 'error',
title: 'Error checking availability',
text: 'Failed to verify if the subdomain is available.',
})
return
}
}
// Save preferences to localStorage
userPreferences.value = { ...newUserPreferences.value }
await queryClient.invalidateQueries({
queryKey: ['servers', 'detail', serverId],
})
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetGeneral = () => {
serverName.value = data.value?.name || ''
serverSubdomain.value = data.value?.net?.domain ?? ''
newUserPreferences.value = { ...userPreferences.value }
}
</script>

View File

@@ -0,0 +1,5 @@
export { default as ServerSettingsAdvancedPage } from './advanced.vue'
export { default as ServerSettingsGeneralPage } from './general.vue'
export { default as ServerSettingsInstallationPage } from './installation.vue'
export { default as ServerSettingsNetworkPage } from './network.vue'
export { default as ServerSettingsPropertiesPage } from './properties.vue'

View File

@@ -0,0 +1,872 @@
<template>
<div class="flex flex-col gap-6">
<Teleport to="body">
<div class="relative z-[100]">
<ConfirmModal
ref="resetToOnboardingModal"
:title="formatMessage(messages.resetToOnboardingModalTitle)"
:description="formatMessage(messages.resetToOnboardingModalDescription)"
:proceed-label="formatMessage(messages.resetToOnboardingButton)"
@proceed="confirmResetToOnboarding"
/>
</div>
</Teleport>
<InstallationSettingsLayout ref="installationSettingsLayout" @reset-server="setupModal?.show()">
<template #extra>
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.resetServerTitle)
}}</span>
<div>
<ButtonStyled color="red">
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.resetServerButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">
{{ formatMessage(messages.resetServerDescription) }}
</span>
</div>
</template>
<template #extra-modals>
<Teleport to="body">
<div class="relative z-[100]">
<ServerSetupModal
ref="setupModal"
@reinstall="onReinstall"
@browse-modpacks="onBrowseModpacks"
/>
<UploadProgressModal ref="uploadProgressModal" />
</div>
</Teleport>
</template>
</InstallationSettingsLayout>
<div v-if="isSiteAdmin" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.supportOptionsTitle) }}
</span>
<div>
<ButtonStyled color="red">
<button
class="!shadow-none"
:disabled="!worldId || isResettingToOnboarding"
@click="resetToOnboardingModal?.show()"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.resetToOnboardingButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Archon, LauncherMeta } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectServerSettings,
injectTags,
InstallationSettingsLayout,
provideInstallationSettings,
ServerSetupModal,
UploadProgressModal,
useDebugLogger,
useModrinthServersConsole,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { injectFilePicker } from '#ui/providers/file-picker'
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 serverSettings = injectServerSettings()
const filePicker = injectFilePicker()
const modrinthServersConsole = useModrinthServersConsole()
const uploadProgressModal =
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
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',
},
supportOptionsTitle: {
id: 'hosting.loader.support-options-title',
defaultMessage: 'Support options',
},
resetToOnboardingButton: {
id: 'hosting.loader.reset-to-onboarding-button',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalTitle: {
id: 'hosting.loader.reset-to-onboarding-modal-title',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalDescription: {
id: 'hosting.loader.reset-to-onboarding-modal-description',
defaultMessage:
'This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?',
},
resetToOnboardingSuccessTitle: {
id: 'hosting.loader.reset-to-onboarding-success-title',
defaultMessage: 'Server reset to onboarding',
},
resetToOnboardingSuccessDescription: {
id: 'hosting.loader.reset-to-onboarding-success-description',
defaultMessage: 'The server has been returned to the onboarding flow.',
},
failedToResetToOnboarding: {
id: 'hosting.loader.failed-to-reset-to-onboarding',
defaultMessage: 'Failed to reset server to onboarding',
},
})
const emit = defineEmits<{
reinstall: [unknown?]
'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 modpackProjectId = computed(() => {
const spec = modpack.value?.spec
return spec?.platform === 'modrinth' ? spec.project_id : null
})
const modpackVersionsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpackProjectId.value]),
queryFn: () =>
client.labrinth.versions_v2.getProjectVersions(modpackProjectId.value!, {
include_changelog: false,
}),
enabled: computed(() => !!modpackProjectId.value),
})
const isSiteAdmin = computed(() => serverSettings.currentUserRole.value === 'admin')
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const resetToOnboardingModal = ref<InstanceType<typeof ConfirmModal>>()
const isResettingToOnboarding = ref(false)
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,
})
const paperSupportedVersionsQuery = useQuery({
queryKey: ['paper-supported-versions'] as const,
queryFn: async () => {
const project = await client.paper.versions_v3.getProject()
return new Set(Object.values(project.versions).flat())
},
enabled: computed(() => editingPlatform.value === 'paper'),
staleTime: 5 * 60 * 1000,
})
const purpurSupportedVersionsQuery = useQuery({
queryKey: ['purpur-supported-versions'] as const,
queryFn: async () => {
const project = await client.purpur.versions_v2.getProject()
return new Set(project.versions)
},
enabled: computed(() => editingPlatform.value === 'purpur'),
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:', modpackProjectId.value)
return val
}),
isBusy: isInstalling,
modpack: computed(() => {
if (!modpack.value) return null
const isLocal = modpack.value.spec.platform === 'local_file'
return {
iconUrl: modpack.value.icon_url,
title:
modpack.value.title ?? (isLocal ? modpack.value.spec.name : modpack.value.spec.project_id),
link: modpackProjectId.value ? `/project/${modpackProjectId.value}` : undefined,
versionNumber: modpack.value.version_number,
filename: isLocal ? modpack.value.spec.filename : undefined,
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') {
if (loader === 'paper') {
const supported = paperSupportedVersionsQuery.data.value
if (supported) {
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
} else if (loader === 'purpur') {
const supported = purpurSupportedVersionsQuery.data.value
if (supported) {
return versions
.filter((v) => supported.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
} else {
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') {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
if (loader === 'paper') {
const supported = paperSupportedVersionsQuery.data.value
if (!supported) return false
return tags.gameVersions.value.some(
(v) => v.version_type !== 'release' && supported.has(v.version),
)
}
if (loader === 'purpur') {
const supported = purpurSupportedVersionsQuery.data.value
if (!supported) return false
return tags.gameVersions.value.some(
(v) => v.version_type !== 'release' && supported.has(v.version),
)
}
const manifest = manifestQuery.data.value?.gameVersions
if (!manifest) return false
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (hasPlaceholder) {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).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
const gameVersionChanged = gameVersion !== (server.value?.mc_version ?? '')
const loaderVersionChanged =
loaderVersionId !== null && loaderVersionId !== (server.value?.loader_version ?? '')
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged || loaderVersionChanged
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
if (platformChanged || loaderVersionChanged) {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: resolvedLoaderVersion ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
debug('save: platform/loader version changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else if (gameVersionChanged) {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
debug('save: succeeded, 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
if (modpack.value.spec.platform === 'local_file') {
debug('reinstallModpack: local file, opening file picker')
const picked = await filePicker.pickModpackFile()
if (!picked) return
try {
const handle = client.kyros.content_v1.uploadModpackFile(
worldId.value!,
picked.file,
{ known: {} },
{ softOverride: true },
)
await uploadProgressModal.value!.track(handle)
emit('reinstall')
invalidateServerState()
} catch (err) {
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
})
}
return
}
if (modpack.value.spec.platform !== 'modrinth') 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: true,
})
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:', modpackProjectId.value)
if (!modpackProjectId.value) throw new Error('No modpack project ID')
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(
modpackProjectId.value,
{
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 (!modpackProjectId.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: modpackProjectId.value,
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: serverSettings.isApp.value,
currentVersionId:
modpack.value?.spec.platform === 'modrinth' ? modpack.value.spec.version_id : '',
projectIconUrl: modpack.value?.icon_url ?? undefined,
projectName:
modpack.value?.title ?? modpackProjectId.value ?? formatMessage(commonMessages.modpackLabel),
currentGameVersion: addonsQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
currentLoader: addonsQuery.data.value?.modloader ?? server.value?.loader ?? '',
})),
isServer: true,
isApp: serverSettings.isApp.value,
showModpackVersionActions: computed(() => modpack.value?.spec.platform === 'modrinth'),
isLocalFile: computed(() => modpack.value?.spec.platform === 'local_file'),
lockPlatform: false,
hideLoaderVersion: false,
async disableAllContent() {
debug('disableAllContent: fetching all addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const items = (addons.addons ?? [])
.filter((a) => !a.disabled)
.map((a) => ({ kind: a.kind, filename: a.filename }))
if (items.length > 0) {
debug('disableAllContent: disabling', items.length, 'addons')
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
}
debug('disableAllContent: done')
},
async disableIncompatibleContent(targetGameVersion) {
debug('disableIncompatibleContent: fetching addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const activeAddons = (addons.addons ?? []).filter((a) => !a.disabled)
const modrinthAddons = activeAddons.filter((a) => a.version?.id)
const customAddons = activeAddons.filter((a) => !a.version?.id)
const incompatibleItems: { kind: (typeof activeAddons)[number]['kind']; filename: string }[] =
customAddons.map((a) => ({ kind: a.kind, filename: a.filename }))
if (modrinthAddons.length > 0) {
const versionIds = modrinthAddons.map((a) => a.version!.id)
const versions = await client.labrinth.versions_v2.getVersions(versionIds)
const incompatibleVersionIds = new Set(
versions.filter((v) => !v.game_versions.includes(targetGameVersion)).map((v) => v.id),
)
for (const addon of modrinthAddons) {
if (incompatibleVersionIds.has(addon.version!.id)) {
incompatibleItems.push({ kind: addon.kind, filename: addon.filename })
}
}
}
if (incompatibleItems.length > 0) {
debug('disableIncompatibleContent: disabling', incompatibleItems.length, 'addons')
await client.archon.content_v1.disableAddons(serverId, worldId.value!, incompatibleItems)
}
debug('disableIncompatibleContent: done')
},
async saveWithoutAutoFix(platform, gameVersion, loaderVersionId) {
debug('saveWithoutAutoFix: called with', { platform, gameVersion, loaderVersionId })
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
emit('reinstall', { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion })
try {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: resolvedLoaderVersion ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
debug('saveWithoutAutoFix: calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
debug('saveWithoutAutoFix: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('saveWithoutAutoFix: failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
})
throw err
}
},
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?: unknown) {
installationSettingsLayout.value?.cancelEditing()
modrinthServersConsole.clear()
queryClient.removeQueries({ queryKey: ['servers', 'ws-state', serverId] })
emit('reinstall', event)
serverSettings.closeModal?.()
}
function onBrowseModpacks() {
debug('onBrowseModpacks: navigating to modpack discovery')
serverSettings.browseModpacks({
serverId,
worldId: worldId.value,
from: 'reset-server',
})
}
async function confirmResetToOnboarding() {
if (!worldId.value) return
try {
isResettingToOnboarding.value = true
await client.archon.servers_v1.resetToOnboarding(serverId, worldId.value)
server.value.flows = { intro: true }
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] }),
])
addNotification({
type: 'success',
title: formatMessage(messages.resetToOnboardingSuccessTitle),
text: formatMessage(messages.resetToOnboardingSuccessDescription),
})
serverSettings.closeModal?.()
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToResetToOnboarding),
})
} finally {
isResettingToOnboarding.value = false
}
}
</script>

View File

@@ -0,0 +1,411 @@
<template>
<div>
<Teleport to="body">
<div class="relative z-[100]">
<NewModal ref="editAllocationModal" header="Edit allocation" width="550px">
<form class="flex w-full flex-col gap-2" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<StyledInput
id="edit-allocation-name"
ref="editAllocationInput"
v-model="editAllocationName"
wrapper-class="w-full"
:maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-end gap-2.5">
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!editAllocationName || creatingAllocation" type="submit">
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
@proceed="confirmDeleteAllocation"
/>
</div>
</Teleport>
<div class="relative w-full">
<div
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">
<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-semibold">Failed to load network settings</h1>
</div>
<p class="text-md text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
allocationsError?.message ?? 'Unknown error'
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
<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 justify-between gap-4">
<div class="flex h-full flex-col gap-6">
<!-- Allocations section -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Allocations</span>
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
<StyledInput
v-model="createAllocationName"
wrapper-class="grow max-w-[400px]"
:maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<ButtonStyled color="brand">
<button
v-tooltip="!createAllocationName ? 'Enter a name to create an allocation' : ''"
:disabled="!createAllocationName || creatingAllocation"
@click="addNewAllocation"
>
<PlusIcon />
<span>Create allocation</span>
</button>
</ButtonStyled>
</div>
<Table :columns="allocationColumns" :data="allocationRows" row-key="port">
<template #cell-name="{ row }">
<TagItem v-if="row.primary" class="!font-medium">Primary</TagItem>
<span v-else class="font-semibold">{{ row.name }}</span>
</template>
<template #cell-port="{ row }">
<span class="font-medium">{{ row.port }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<ButtonStyled icon-only type="transparent" circular>
<button @click="copyText(`${serverIP}:${row.port}`)">
<CopyIcon />
</button>
</ButtonStyled>
<template v-if="!row.primary">
<ButtonStyled icon-only type="transparent" circular>
<button @click="showEditAllocationModal(row.port)">
<PencilIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only type="outlined" circular color="red">
<button @click="showConfirmDeleteModal(row.port)">
<TrashIcon />
</button>
</ButtonStyled>
</template>
</div>
</template>
</Table>
<span>
Create additional ports for internet-facing features like map viewers or voice chat
mods.
</span>
</div>
<!-- DNS records section -->
<div class="flex flex-col gap-2.5">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">DNS records</span>
</label>
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
<StyledInput
id="user-domain"
v-model="userDomain"
wrapper-class="grow max-w-[400px]"
:maxlength="64"
:placeholder="exampleDomain"
/>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export</span>
</button>
</ButtonStyled>
</div>
<Table :columns="dnsColumns" :data="dnsRecords">
<template #cell-type="{ row }">
<TagItem
v-if="row.type === 'SRV'"
class="border !border-solid border-purple bg-highlight-purple !font-medium"
:style="`--_color: var(--color-purple)`"
>
{{ row.type }}
</TagItem>
<TagItem
v-else
class="border !border-solid border-blue bg-highlight-blue !font-medium"
:style="`--_color: var(--color-blue)`"
>
{{ row.type }}
</TagItem>
</template>
<template #cell-name="{ row }">
<span
class="block cursor-pointer truncate pr-8 font-semibold"
@click="copyText(row.name)"
>
{{ row.name }}
</span>
</template>
<template #cell-content="{ row }">
<span
class="block cursor-pointer truncate pr-8 font-semibold"
@click="copyText(row.content)"
>
{{ row.content }}
</span>
</template>
</Table>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
CopyIcon,
IssuesIcon,
PencilIcon,
PlusIcon,
SaveIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
import type { TableColumn } from '#ui/components/base'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const data = server
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
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 allocationColumns: TableColumn[] = [
{ key: 'name', label: 'Name', width: '40%' },
{ key: 'port', label: 'Port' },
{ key: 'actions', label: 'Actions', width: '33%', align: 'right' },
]
const allocationRows = computed(() => {
const primary = {
name: 'Primary allocation',
port: serverPrimaryPort.value,
primary: true,
}
const extra = (allocations.value ?? []).map((a) => ({
name: a.name,
port: a.port,
primary: false,
}))
return [primary, ...extra]
})
const dnsColumns: TableColumn[] = [
{ key: 'type', label: 'Type', width: '20%' },
{ key: 'name', label: 'Name', width: '35%' },
{ key: 'content', label: 'Content' },
]
const editAllocationModal = ref<typeof NewModal>()
const confirmDeleteModal = ref<typeof ConfirmModal>()
const editAllocationInput = ref<HTMLInputElement | null>(null)
const createAllocationName = ref('')
const editAllocationName = ref('')
const newAllocationPort = ref(0)
const allocationToDelete = ref<number | null>(null)
const creatingAllocation = ref(false)
const addNewAllocation = async () => {
if (!createAllocationName.value) return
creatingAllocation.value = true
try {
await client.archon.servers_v0.reserveAllocation(serverId, createAllocationName.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
createAllocationName.value = ''
addNotification({
type: 'success',
title: 'Allocation reserved',
text: 'Your allocation has been reserved.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
} finally {
creatingAllocation.value = false
}
}
const showEditAllocationModal = (port: number) => {
newAllocationPort.value = port
editAllocationName.value = allocations.value?.find((a) => a.port === port)?.name ?? ''
editAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus()
}, 100)
})
}
const showConfirmDeleteModal = (port: number) => {
allocationToDelete.value = port
confirmDeleteModal.value?.show()
}
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
})
allocationToDelete.value = null
}
const editAllocation = async () => {
if (!editAllocationName.value) return
creatingAllocation.value = true
try {
await client.archon.servers_v0.updateAllocation(
serverId,
newAllocationPort.value,
editAllocationName.value,
)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
editAllocationModal.value?.hide()
editAllocationName.value = ''
addNotification({
type: 'success',
title: 'Allocation updated',
text: 'Your allocation has been updated.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
} finally {
creatingAllocation.value = false
}
}
const dnsRecords = computed(() => {
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
return [
{
type: 'A',
name: `${domain}`,
content: data.value?.net?.ip ?? '',
},
{
type: 'SRV',
name: `_minecraft._tcp.${domain}`,
content: `0 10 ${data.value?.net?.port} ${domain}`,
},
]
})
type DnsRecord = {
type: string
name: string
content: string
}
const exportDnsRecords = () => {
const records = dnsRecords.value.reduce(
(acc, record) => {
const type = record.type
if (!acc[type]) {
acc[type] = []
}
acc[type].push(record)
return acc
},
{} as Record<string, DnsRecord[]>,
)
const text = Object.entries(records)
.map(([type, records]) => {
return `; ${type} Records\n${records.map((record) => `${record.name}.\t1\tIN\t${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
})
.join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const a = document.createElement('a')
a.href = window.URL.createObjectURL(blob)
a.download = `${userDomain.value}.txt`
a.click()
a.remove()
}
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
})
}
</script>

View File

@@ -0,0 +1,560 @@
<template>
<div class="relative h-screen w-full select-none max-h-[min(70vh,750px)]">
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4">
<Admonition
v-if="hasNoProperties"
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="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="m-0">
Edit the Minecraft server properties file here, or use the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab
</AutoLink>
to edit the full file. If you're unsure about a setting, the
<AutoLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
target="_blank"
>
Minecraft Wiki
</AutoLink>
has more details.
</div>
</div>
<div class="w-full text-sm">
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div class="flex flex-col gap-3 pb-2">
<div class="flex flex-col gap-6">
<!-- Basic Properties -->
<!-- [&:not(:has(*:not(:empty)))]:hidden is to hide parent if all children are empty -->
<div
class="rounded-2xl border border-solid border-surface-5 p-4 pb-2 [&:not(:has(*:not(:empty)))]:hidden"
>
<div class="flex w-full flex-col gap-1.5">
<div v-if="isPropertyVisible('gamemode')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Gamemode</span>
<Chips
v-model="combinedGamemode"
:items="gamemodeItems"
:format-label="capitalize"
/>
</div>
<div
v-if="combinedGamemode !== 'hardcore' && isPropertyVisible('difficulty')"
class="flex flex-col gap-2.5 my-1"
>
<span class="font-semibold text-contrast">Difficulty</span>
<Chips
v-model="selectedDifficulty"
:items="difficultyItems"
:format-label="capitalize"
/>
</div>
<div v-if="isPropertyVisible('max_players')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Max players</span>
<StyledInput
id="server-property-max-players"
:model-value="liveProperties.max_players"
type="number"
placeholder="20"
wrapper-class="w-full max-w-[450px]"
@update:model-value="liveProperties.max_players = String($event)"
/>
</div>
<div v-if="isPropertyVisible('motd')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">MOTD</span>
<StyledInput
id="server-property-motd"
v-model="liveProperties.motd"
placeholder="A Minecraft Server"
wrapper-class="w-full max-w-[450px]"
/>
</div>
<div
v-if="isPropertyVisible('allow_flight')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow flight</span>
<Toggle
id="server-property-allow-flight"
:model-value="liveProperties.allow_flight === 'true'"
@update:model-value="liveProperties.allow_flight = $event ? 'true' : 'false'"
/>
</div>
<div
v-if="isPropertyVisible('allow_cheats')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow cheats</span>
<Toggle
id="server-property-allow-cheats"
:model-value="liveProperties.allow_cheats === 'true'"
@update:model-value="liveProperties.allow_cheats = $event ? 'true' : 'false'"
/>
</div>
<div
v-if="isPropertyVisible('white_list')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable whitelist</span>
<Toggle id="server-property-whitelist" v-model="whitelistEnabled" />
</div>
<div
v-if="isPropertyVisible('spawn_protection')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable spawn protection</span>
<Toggle
id="server-property-spawn-protection-toggle"
v-model="spawnProtectionEnabled"
/>
</div>
<div
v-if="spawnProtectionEnabled && isPropertyVisible('spawn_protection')"
class="flex items-center justify-between h-10"
>
<span class="font-semibold text-contrast">Protection radius</span>
<StyledInput
id="server-property-spawn-protection-radius"
:model-value="liveProperties.spawn_protection"
type="number"
wrapper-class="w-full sm:w-[100px]"
input-class="text-right"
@update:model-value="liveProperties.spawn_protection = String($event)"
/>
</div>
</div>
</div>
</div>
<!-- Advanced Properties -->
<Accordion
v-if="hasVisibleAdvancedProperties"
overflow-visible
:force-open="isSearchActive"
button-class="flex w-full flex-col gap-2 bg-transparent m-0 p-0 border-none"
>
<template #title>
<span class="text-lg font-semibold text-contrast">Advanced properties</span>
</template>
<div class="flex flex-col gap-6 pt-4">
<template v-for="group in advancedGroupedProperties" :key="group.label">
<div v-if="hasVisibleProperties(group)" class="flex flex-col gap-2.5">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ group.label }}
</h3>
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 p-4"
>
<template v-for="key in group.properties" :key="key">
<div
v-if="isPropertyVisible(key)"
class="flex flex-row flex-wrap items-center justify-between h-10"
>
<span :id="`property-label-${key}`" class="font-semibold text-contrast">
{{ formatPropertyName(key) }}
</span>
<div
v-if="getPropertyDef(key).type === 'toggle'"
class="flex w-full justify-end sm:w-[320px]"
>
<Toggle
: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="getPropertyDef(key).type === 'number'"
class="w-full sm:w-[320px]"
>
<StyledInput
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
<div v-else class="flex w-full justify-end sm:w-[320px]">
<StyledInput
:id="`server-property-${key}`"
v-model="liveProperties[key]"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<div>
All other properties can be edited in server.properties via the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab </AutoLink
>.
</div>
</div>
</Accordion>
<div
v-if="hasNoResults"
class="flex flex-col items-center gap-2 py-8 text-center text-secondary"
>
<SearchIcon class="size-10" />
<span class="text-lg font-semibold text-contrast">No properties found</span>
<span>No properties match "{{ searchInput }}".</span>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full w-full items-center justify-center">
<SpinnerIcon class="animate-spin" />
</div>
<SaveBanner
:is-visible="hasUnsavedChanges || isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:save="
async () => {
await saveProperties()
}
"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const filesTabLink = computed(
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
)
const serverSettings = injectServerSettings(null)
const searchInput = ref('')
function onFilesTabLinkClick() {
serverSettings?.closeModal?.()
}
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' }
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
allow_cheats: { type: 'toggle' },
allow_flight: { type: 'toggle' },
difficulty: { type: 'text' },
enforce_whitelist: { type: 'toggle' },
force_gamemode: { type: 'toggle' },
gamemode: { type: 'text' },
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' },
}
function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
const ADVANCED_GROUPS = [
{
label: 'Performance',
keys: [
'view_distance',
'simulation_distance',
'sync_chunk_writes',
'max_tick_time',
'player_idle_timeout',
'pause_when_empty_seconds',
],
},
{
label: 'Resource Pack',
keys: ['resource_pack', 'resource_pack_id', 'resource_pack_sha1', 'require_resource_pack'],
},
]
type CombinedGamemode = 'survival' | 'creative' | 'hardcore'
const gamemodeItems: CombinedGamemode[] = ['survival', 'creative', 'hardcore']
const difficultyItems = ['peaceful', 'easy', 'normal', 'hard']
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
const { data: propsData } = useQuery({
queryKey,
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
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>>({})
let previousSpawnProtection = '16'
function syncFormFromData() {
if (!propsData.value) return
const flat = flattenProperties(propsData.value)
liveProperties.value = { ...flat }
originalProperties.value = { ...flat }
const sp = flat.spawn_protection
if (sp && sp !== '0') {
previousSpawnProtection = sp
}
}
const hasNoProperties = computed(() => Object.keys(liveProperties.value).length === 0)
const hasUnsavedChanges = computed(() =>
Object.keys(liveProperties.value).some(
(key) => liveProperties.value[key] !== originalProperties.value[key],
),
)
watch(
propsData,
(newData) => {
if (newData && !hasUnsavedChanges.value) {
syncFormFromData()
}
},
{ immediate: true },
)
watch(powerState, () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
})
const combinedGamemode = computed<CombinedGamemode>({
get() {
if (liveProperties.value.hardcore === 'true') return 'hardcore'
if (liveProperties.value.gamemode === 'creative') return 'creative'
return 'survival'
},
set(value) {
if (value === 'hardcore') {
liveProperties.value.gamemode = 'survival'
liveProperties.value.hardcore = 'true'
liveProperties.value.difficulty = 'hard'
} else {
liveProperties.value.gamemode = value
liveProperties.value.hardcore = 'false'
}
},
})
const selectedDifficulty = computed({
get: () => liveProperties.value.difficulty ?? 'normal',
set: (v: string) => {
liveProperties.value.difficulty = v
},
})
const whitelistEnabled = computed({
get: () => liveProperties.value.white_list === 'true',
set: (v: boolean) => {
liveProperties.value.white_list = v ? 'true' : 'false'
liveProperties.value.enforce_whitelist = v ? 'true' : 'false'
},
})
const spawnProtectionEnabled = computed({
get: () => {
const val = liveProperties.value.spawn_protection
return val !== undefined && val !== '0'
},
set: (enabled: boolean) => {
if (enabled) {
liveProperties.value.spawn_protection = previousSpawnProtection || '16'
} else {
previousSpawnProtection = liveProperties.value.spawn_protection || '16'
liveProperties.value.spawn_protection = '0'
}
},
})
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
const known: Record<string, string> = {}
const custom: Record<string, string> = {}
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 {
custom[key] = liveProperties.value[key]
}
}
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 { mutateAsync: 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.',
})
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: error instanceof Error ? error.message : 'An error occurred.',
})
},
})
function resetProperties() {
syncFormFromData()
}
const advancedGroupedProperties = computed(() =>
ADVANCED_GROUPS.map((group) => ({
label: group.label,
properties: group.keys.filter((key) => key in liveProperties.value),
})).filter((g) => g.properties.length > 0),
)
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 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 isSearchActive = computed(() => !!searchInput.value?.trim())
const hasNoResults = computed(
() => isSearchActive.value && Object.keys(filteredProperties.value).length === 0,
)
function isPropertyVisible(key: string): boolean {
if (!isSearchActive.value) return true
return key in filteredProperties.value
}
function hasVisibleProperties(group: { properties: string[] }): boolean {
return group.properties.some((key) => isPropertyVisible(key))
}
const hasVisibleAdvancedProperties = computed(() =>
advancedGroupedProperties.value.some((group) => hasVisibleProperties(group)),
)
function formatPropertyName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
</script>

View File

@@ -0,0 +1 @@
export * from './server-settings'

View File

@@ -0,0 +1,22 @@
import type { Ref } from 'vue'
import { createContext } from '#ui/providers/create-context'
export interface ServerSettingsBrowseModpacksArgs {
serverId: string
worldId: string | null
from: 'reset-server'
}
export interface ServerSettingsContext {
isApp: Ref<boolean>
currentUserId: Ref<string | null>
currentUserRole: Ref<string | null>
browseModpacks: (args: ServerSettingsBrowseModpacksArgs) => void | Promise<void>
closeModal?: () => void
}
export const [injectServerSettings, provideServerSettings] = createContext<ServerSettingsContext>(
'ServerSettings',
'serverSettingsContext',
)

View File

@@ -0,0 +1,82 @@
import type { Archon } from '@modrinth/api-client'
import {
CardIcon,
ListIcon,
ModrinthIcon,
SettingsIcon,
TextQuoteIcon,
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import type { Component } from 'vue'
export type ServerSettingsTabId =
| 'general'
| 'installation'
| 'network'
| 'properties'
| 'advanced'
| 'billing'
| 'admin-billing'
export interface ServerSettingsTabContext {
serverId: string
ownerId: string
serverStatus?: Archon.Servers.v0.Status | null
isOwner: boolean
isAdmin: boolean
}
export interface ServerSettingsTabDefinition {
id: ServerSettingsTabId
label: string
icon: Component
href?: (ctx: ServerSettingsTabContext) => string
external?: boolean
shown?: (ctx: ServerSettingsTabContext) => boolean
}
export const serverSettingsTabDefinitions: ServerSettingsTabDefinition[] = [
{
id: 'general',
label: 'General',
icon: SettingsIcon,
},
{
id: 'installation',
label: 'Installation',
icon: WrenchIcon,
},
{
id: 'network',
label: 'Network',
icon: VersionIcon,
},
{
id: 'properties',
label: 'Properties',
icon: ListIcon,
shown: ({ serverStatus }) => serverStatus !== 'installing',
},
{
id: 'advanced',
label: 'Advanced',
icon: TextQuoteIcon,
},
{
id: 'billing',
label: 'Billing',
icon: CardIcon,
href: ({ serverId }) => `/settings/billing#server-${serverId}`,
external: true,
shown: ({ isOwner }) => isOwner,
},
{
id: 'admin-billing',
label: 'Admin Billing',
icon: ModrinthIcon,
href: ({ ownerId }) => `/admin/billing/${ownerId}`,
external: true,
shown: ({ isAdmin }) => isAdmin,
},
]