feat: implement improved flow for server panel edit installation (#5711)

* feat: implement improved flow for server panel edit installation

* feat: installation form finalized

* feat: error state for InstallingBanner

* feat: action button refactor + save banner text fix

* fix: lint

* fix: content card alignment

* feat: better copy

* fix: lint

* fix: hide shift click + fix NeoForge chip

* fix: lint
This commit is contained in:
Calum H.
2026-04-01 19:53:19 +02:00
committed by GitHub
parent c52abece44
commit fa4711ff7b
19 changed files with 785 additions and 168 deletions

View File

@@ -4,19 +4,19 @@
<div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">
Are you sure you want to
<span class="lowercase">{{ confirmActionText }}</span> the server?
<span class="lowercase">{{ pendingAction }}</span> the server?
</p>
<Checkbox
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!powerAction"
:disabled="!pendingAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ confirmActionText }} server
{{ pendingAction }} server
</button>
</ButtonStyled>
<ButtonStyled @click="resetPowerAction">
@@ -31,11 +31,11 @@
<NewModal
ref="detailsModal"
:header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal"
:header="`All of ${server.name || 'Server'} info`"
@close="detailsModal?.hide()"
>
<ServerInfoLabels
:server-data="serverData"
:server-data="server"
:show-game-label="true"
:show-loader-label="true"
:uptime-seconds="uptimeSeconds"
@@ -43,9 +43,9 @@
class="mb-6 flex flex-col gap-2"
/>
<div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ serverData }}</pre>
<pre>{{ server }}</pre>
</div>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<ButtonStyled type="standard" color="brand" @click="detailsModal?.hide()">
<button class="w-full">Close</button>
</ButtonStyled>
</NewModal>
@@ -62,14 +62,14 @@
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? 'Stopping...' : 'Stop' }}</span>
<span>{{ isStopping ? 'Stopping...' : 'Stop' }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitioning" class="grid place-content-center">
<LoadingIcon />
</div>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
@@ -116,8 +116,16 @@ import {
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui'
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
import {
ButtonStyled,
Checkbox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
ServerInfoLabels,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -126,70 +134,60 @@ import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags()
interface PowerAction {
action: ServerPowerAction
nextState: ServerState
}
type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
const props = defineProps<{
isOnline: boolean
isActioning: boolean
isInstalling: boolean
disabled: boolean
serverName?: string
serverData: object
disabled?: boolean
uptimeSeconds: number
busyReason?: string
}>()
const emit = defineEmits<{
(e: 'action', action: ServerPowerAction): void
}>()
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const router = useRouter()
const serverId = router.currentRoute.value.params.id
const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
const pendingAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
})
const serverState = ref<ServerState>(props.isOnline ? 'running' : 'stopped')
const powerAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const startingDelay = ref(false)
const isInstalling = computed(() => server.value.status === 'installing')
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const showStopButton = computed(() => isRunning.value || isStopping.value)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value && !props.busyReason,
() => !isTransitioning.value && !props.disabled && busyReasons.value.length === 0,
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>
['starting', 'stopping', 'restarting'].includes(serverState.value),
)
const isStoppingState = computed(() => serverState.value === 'stopping')
const showStopButton = computed(() => isRunning.value || isStoppingState.value)
const primaryActionText = computed(() => {
const states: Partial<Record<ServerState, string>> = {
starting: 'Starting...',
restarting: 'Restarting...',
running: 'Restart',
stopping: 'Stopping...',
stopped: 'Start',
switch (powerState.value) {
case 'starting':
return 'Starting...'
case 'stopping':
return 'Stopping...'
case 'running':
return 'Restart'
default:
return 'Start'
}
return states[serverState.value]
})
const confirmActionText = computed(() => {
if (!powerAction.value) return ''
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1)
})
const menuOptions = computed(() => [
...(props.isInstalling
...(isInstalling.value
? []
: [
{
@@ -221,28 +219,31 @@ const menuOptions = computed(() => [
])
async function copyId() {
await navigator.clipboard.writeText(serverId as string)
await navigator.clipboard.writeText(serverId)
}
function initiateAction(action: ServerPowerAction) {
async function sendPowerAction(action: PowerAction) {
try {
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error performing ${action} on server:`, error)
addNotification({
type: 'error',
title: `Failed to ${action.toLowerCase()} server`,
text: 'An error occurred while performing this action.',
})
}
}
function initiateAction(action: PowerAction) {
if (!canTakeAction.value) return
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: 'starting',
Stop: 'stopping',
Restart: 'restarting',
Kill: 'stopping',
}
if (action === 'Start') {
emit('action', action)
serverState.value = stateMap[action]
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
sendPowerAction(action)
return
}
powerAction.value = { action, nextState: stateMap[action] }
pendingAction.value = action
if (userPreferences.value.powerDontAskAgain) {
executePowerAction()
@@ -256,41 +257,20 @@ function handlePrimaryAction() {
}
function executePowerAction() {
if (!powerAction.value) return
if (!pendingAction.value) return
const { action, nextState } = powerAction.value
emit('action', action)
serverState.value = nextState
sendPowerAction(pendingAction.value)
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true
}
if (action === 'Start') {
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
}
resetPowerAction()
}
function resetPowerAction() {
confirmActionModal.value?.hide()
powerAction.value = null
pendingAction.value = null
dontAskAgain.value = false
}
function closeDetailsModal() {
detailsModal.value?.hide()
}
watch(
() => props.isOnline,
(online) => (serverState.value = online ? 'running' : 'stopped'),
)
watch(
() => router.currentRoute.value.fullPath,
() => closeDetailsModal(),
)
</script>

View File

@@ -18,8 +18,8 @@
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart">
{{ props.isUpdating ? 'Saving...' : 'Save & restart' }}
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
{{ powerButtonLabel }}
</button>
</ButtonStyled>
</div>
@@ -30,7 +30,8 @@
</template>
<script setup lang="ts">
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
import { ButtonStyled, injectModrinthClient, injectModrinthServerContext } from '@modrinth/ui'
import { computed } from 'vue'
const props = defineProps<{
isUpdating: boolean
@@ -42,10 +43,23 @@ const props = defineProps<{
}>()
const client = injectModrinthClient()
const { powerState } = injectModrinthServerContext()
const saveAndRestart = async () => {
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const powerButtonLabel = computed(() => {
if (props.isUpdating) return 'Saving...'
if (isTransitioning.value) return isStopped.value ? 'Save & start' : 'Save & restart'
return isStopped.value ? 'Save & start' : 'Save & restart'
})
const saveAndPower = async () => {
props.save()
await client.archon.servers_v0.power(props.serverId, 'Restart')
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
}
</script>

View File

@@ -172,17 +172,7 @@
</template>
<template #actions>
<div v-if="isConnected && !serverData.flows?.intro" class="flex gap-2">
<PanelServerActionButton
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:busy-reason="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
@action="sendPowerAction"
/>
<PanelServerActionButton :disabled="!!error" :uptime-seconds="uptimeSeconds" />
</div>
</template>
</ContentPageHeader>
@@ -320,12 +310,14 @@
>
<InstallingBanner
v-if="
(serverData.status === 'installing' || isSyncingContent) &&
(serverData.status === 'installing' || isSyncingContent || contentError) &&
syncProgress?.phase !== 'Analyzing'
"
data-pyro-server-installing
class="mb-4"
:progress="syncProgress"
:content-error="contentError"
@retry="handleContentRetry"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
@@ -398,9 +390,8 @@ import {
ServerNotice,
ServerOnboardingPanelPage,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { PowerAction, Stats } from '@modrinth/utils'
import type { Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
@@ -416,7 +407,6 @@ 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)
@@ -505,7 +495,6 @@ const modrinthServersConsole = useModrinthServersConsole()
const queryClient = useQueryClient()
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
const isActioning = ref(false)
const isServerRunning = computed(() => serverPowerState.value === 'running')
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
@@ -518,6 +507,7 @@ const markBackupCancelled = (backupId: string) => {
// Parthenon state event
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
const syncProgressActive = ref(false)
const isAwaitingPostInstallRefresh = ref(false)
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
@@ -845,6 +835,7 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
serverStatus: serverData.value?.status,
})
syncProgress.value = data.progress
contentError.value = data.content_error
// Sync power state from the state event
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
@@ -878,6 +869,7 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
hasSeenInstallProgress = true
} else if (
data.progress == null &&
data.content_error == null &&
serverData.value.status === 'installing' &&
hasSeenInstallProgress
) {
@@ -889,6 +881,18 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
}
}
async function handleContentRetry() {
if (!worldId.value) return
try {
await client.archon.content_v1.repair(serverId, worldId.value)
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to retry installation',
})
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
@@ -1219,43 +1223,6 @@ const updateGraphData = (dataArray: number[], newValue: number): number[] => {
return updated
}
const toAdverb = (word: string) => {
if (word.endsWith('p')) {
return word + 'ping'
}
if (word.endsWith('e')) {
return word.slice(0, -1) + 'ing'
}
if (word.endsWith('ie')) {
return word.slice(0, -2) + 'ying'
}
return word + 'ing'
}
const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
try {
isActioning.value = true
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error)
notifyError(
`Error ${toAdverb(actionName)} server`,
'An error occurred while performing this action.',
)
} finally {
isActioning.value = false
}
}
const notifyError = (title: string, text: string) => {
addNotification({
title,
text,
type: 'error',
})
}
const nodeUnavailableDetails = computed(() => [
{
label: 'Server ID',

View File

@@ -8,7 +8,7 @@
@proceed="confirmResetToOnboarding"
/>
<InstallationSettingsLayout ref="installationSettingsLayout">
<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">{{
@@ -455,6 +455,9 @@ provideInstallationSettings({
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') {
@@ -465,12 +468,12 @@ provideInstallationSettings({
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged
platformChanged || loaderVersionChanged
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
if (platformChanged) {
if (platformChanged || loaderVersionChanged) {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
@@ -478,9 +481,9 @@ provideInstallationSettings({
game_version: gameVersion || undefined,
soft_override: true,
}
debug('save: platform changed, calling installContent', request)
debug('save: platform/loader version changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else {
} else if (gameVersionChanged) {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
@@ -662,8 +665,66 @@ provideInstallationSettings({
isApp: false,
showModpackVersionActions: computed(() => modpack.value?.spec.platform === 'modrinth'),
lockPlatform: true,
hideLoaderVersion: true,
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(diffs) {
debug('disableIncompatibleContent: processing', diffs.length, 'diffs')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const removedFiles = new Set(diffs.filter((d) => d.type === 'removed').map((d) => d.fileName))
const items = (addons.addons ?? [])
.filter((a) => !a.disabled && removedFiles.has(a.filename))
.map((a) => ({ kind: a.kind, filename: a.filename }))
if (items.length > 0) {
debug('disableIncompatibleContent: disabling', items.length, 'addons')
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
}
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(