feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
This commit is contained in:
Calum H.
2026-04-27 19:03:48 +00:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions
@@ -99,7 +99,7 @@
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within relative mx-auto pb-6 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
class="experimental-styles-within relative mx-auto box-border flex w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverImage
? `url(${serverImage})`
@@ -107,9 +107,7 @@
}"
:class="[
'server-panel-' + revealState,
{
'max-w-[1280px]': isNuxt,
},
isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
]"
>
<template v-if="revealState !== 'pending' || isOnboarding">
@@ -309,66 +307,13 @@
Hang on, we're reconnecting to your server.
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<InstallingBanner
v-if="
(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" />
</template>
</InstallingBanner>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<Admonition v-if="uploadState.isUploading" type="info" class="mb-4">
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
Uploading files ({{ uploadState.completedFiles }}/{{ uploadState.totalFiles }})
<span v-if="uploadState.currentFileName" class="font-normal text-secondary">
— {{ uploadState.currentFileName }}
</span>
</template>
<span class="text-secondary">
{{ formatBytes(uploadState.uploadedBytes) }} /
{{ formatBytes(uploadState.totalBytes) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" @click="cancelUpload?.()">Cancel</button>
</ButtonStyled>
</template>
<template #progress>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<FileOperationAdmonitions class="mb-4" />
<BackupProgressAdmonitions class="mb-4" />
<ServerPanelAdmonitions
class="mb-4"
:sync-progress="syncProgress"
:content-error="contentError"
:server-image="serverImage"
@content-retry="handleContentRetry"
/>
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
</div>
</template>
@@ -417,11 +362,9 @@ import {
SettingsIcon,
TransferIcon,
TriangleAlertIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { formatBytes } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
@@ -429,16 +372,12 @@ import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import ServerNotice from '#ui/components/base/ServerNotice.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import BackupProgressAdmonitions from '#ui/components/servers/backups/BackupProgressAdmonitions.vue'
import { ServerIcon } from '#ui/components/servers/icons'
import InstallingBanner from '#ui/components/servers/InstallingBanner.vue'
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue'
import {
PanelServerActionButton,
@@ -455,6 +394,7 @@ import {
useServerProject,
} from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
import type { LogLine } from '#ui/layouts/shared/console'
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
@@ -465,7 +405,6 @@ import {
} from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
import FileOperationAdmonitions from '../../../shared/files-tab/components/FileOperationAdmonitions.vue'
import ServerOnboardingPanelPage from './[id]/onboarding.vue'
interface Tab {
@@ -618,17 +557,17 @@ const worldId = computed(() => {
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
computed(() => props.serverId),
worldId,
)
const { image: serverImage } = useServerImage(
props.serverId,
computed(() => serverData.value?.upstream ?? null),
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const cancelledBackups = new Set<string>()
const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
const syncProgressActive = ref(false)
@@ -686,7 +625,6 @@ const onStateEvent = (data: Archon.Websocket.v0.WSStateEvent) => {
}
const {
backupsState,
cancelUpload,
cleanupCoreRuntime,
connectSocket,
@@ -704,8 +642,7 @@ const {
worldId,
server: serverData,
isSyncingContent,
markBackupCancelled,
includeBackupBusyReasons: true,
extraBusyReasons: backupsBusy,
setDisconnectedOnAuthIncorrect: false,
syncUptimeFromState: true,
incrementUptimeLocally: true,
@@ -713,12 +650,6 @@ const {
onStateEvent,
})
const uploadOverallProgress = computed(() => {
const state = uploadState.value
if (!state.isUploading || state.totalFiles === 0) return 0
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
})
const isUploading = computed(() => uploadState.value.isUploading)
function handleBeforeUnload(e: BeforeUnloadEvent) {
@@ -981,69 +912,7 @@ async function handleContentRetry() {
}
const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) => {
if (data.task === 'file') return
const backupId = data.id
if (cancelledBackups.has(backupId)) return
const current = backupsState.get(backupId) ?? {}
const currentTaskState = current[data.task]?.state
const isIncomingTerminal =
data.state === 'done' || data.state === 'failed' || data.state === 'cancelled'
if (currentTaskState === data.state && isIncomingTerminal) return
const previousProgress = current[data.task]?.progress
if (currentTaskState !== data.state || previousProgress !== data.progress) {
backupsState.set(backupId, {
...current,
[data.task]: {
progress: data.progress,
state: data.state,
},
})
}
if (isIncomingTerminal) {
const attemptCleanup = (attempt: number = 1) => {
queryClient.invalidateQueries({ queryKey: ['backups', 'list', props.serverId] }).then(() => {
const backupData = queryClient.getQueryData<Archon.Backups.v1.Backup[]>([
'backups',
'list',
props.serverId,
])
const backup = backupData?.find((b) => b.id === backupId)
const isStillActive =
backup && (backup.status === 'in_progress' || backup.status === 'pending')
if (isStillActive && attempt < 6) {
setTimeout(() => attemptCleanup(attempt + 1), 1000 * Math.pow(2, attempt - 1))
return
}
if (isStillActive) {
queryClient.setQueryData<Archon.Backups.v1.Backup[]>(
['backups', 'list', props.serverId],
(old) =>
old?.map((b) => {
if (b.id !== backupId) return b
return {
...b,
status: data.state === 'done' ? ('done' as const) : ('error' as const),
ongoing: false,
interrupted: data.state === 'failed',
}
}),
)
}
backupsState.delete(backupId)
})
}
attemptCleanup()
}
handleWsBackupProgress(data)
}
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
@@ -1455,20 +1324,23 @@ function initializeServer() {
}
}
let intercomInitialized = false
const cleanup = () => {
isMounted.value = false
saveWsStateToCache()
shutdown()
if (intercomInitialized) {
shutdown()
intercomInitialized = false
}
cleanupCoreRuntime(props.serverId)
isReconnecting.value = false
isLoading.value = true
cancelledBackups.clear()
DOMPurify.removeHook('afterSanitizeAttributes')
}
@@ -1486,19 +1358,36 @@ onMounted(() => {
})
}
let intercomInitialized = false
const tryInitIntercom = () => {
if (intercomInitialized) return
if (!props.authUser || !props.fetchIntercomToken) return
if (!props.authUser || !props.fetchIntercomToken) {
console.debug('[PYROSERVERS][INTERCOM] waiting for auth user and token fetcher', {
hasAuthUser: !!props.authUser,
hasFetchIntercomToken: !!props.fetchIntercomToken,
})
return
}
intercomInitialized = true
console.debug('[PYROSERVERS][INTERCOM] initializing secure support chat')
props
.fetchIntercomToken()
.then(({ token }) => {
console.debug('[PYROSERVERS][INTERCOM] fetched messenger JWT, booting widget')
Intercom({
app_id: props.intercomAppId!,
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
})
window.setTimeout(() => {
const hasWidget = !!document.querySelector(
'.intercom-lightweight-app, #intercom-container, #intercom-frame',
)
if (!hasWidget) {
console.warn(
'[PYROSERVERS][INTERCOM] boot completed but no Intercom widget was detected',
)
}
}, 2500)
})
.catch((error) => {
intercomInitialized = false