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 20:03:48 +01:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions
@@ -100,7 +100,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags: options.tags, query, maxResults, currentPage })
} = useServerSearch({
tags: options.tags,
query,
maxResults,
currentPage,
providedFilters: options.providedFilters,
})
const effectiveRequestParams = computed(() =>
isServerType.value ? serverRequestParams.value : requestParams.value,
@@ -174,7 +174,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" />
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
@@ -241,7 +241,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" />
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
@@ -98,7 +98,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
>
<Checkbox
v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'"
:label="ctx.hideInstalledLabel?.value ?? 'Hide already installed content'"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
@@ -30,6 +30,7 @@ export interface CardAction {
key: string
label: string
icon: Component
iconClass?: string
disabled?: boolean
color?: 'brand' | 'red'
type?: 'standard' | 'outlined' | 'transparent'
@@ -58,9 +58,10 @@
ref="terminalRef"
class="min-h-0 flex-1"
:show-input="resolvedShowInput"
:disable-input="resolvedDisableInput"
:disable-input="resolvedInputDisabled"
:fullscreen="isFullscreen"
:empty-state-type="ctx.emptyStateType"
:loading="resolvedLoading"
@command="handleCommand"
@ready="handleTerminalReady"
/>
@@ -206,6 +207,15 @@ const resolvedDisableInput = computed(() => {
return isRef(v) ? v.value : v
})
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
if (!v) return false
return v.value
})
const resolvedInputDisabled = computed(() => resolvedDisableInput.value || resolvedLoading.value)
const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled
if (!v) return false
@@ -237,6 +247,11 @@ function rewriteFiltered() {
const term = terminalRef.value?.terminal
if (!term) return
const lines = ctx.logLines.value
if (resolvedLoading.value && lines.length === 0 && isLiveSource.value) {
terminalRef.value?.clearEmptyState()
lastWrittenIndex = 0
return
}
if (lines.length === 0 && isLiveSource.value) {
writeEmptyState()
return
@@ -271,6 +286,12 @@ watch(ctx.logLines, (lines, oldLines) => {
if (!term) return
if (lines.length === 0 && isLiveSource.value) {
if (resolvedLoading.value) {
terminalRef.value?.clearEmptyState()
lastWrittenIndex = 0
return
}
writeEmptyState()
return
}
@@ -312,6 +333,12 @@ watch(searchQuery, () => {
}, 200)
})
watch(resolvedLoading, (loading) => {
if (!loading) {
rewriteFiltered()
}
})
function handleCommand(cmd: string) {
ctx.sendCommand?.(cmd)
}
@@ -1,12 +1,13 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import {
injectAppBackup,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers/'
} from '#ui/providers'
export function useInlineBackup(backupName: string | (() => string)) {
const serverCtx = injectModrinthServerContext(null)
@@ -60,110 +61,65 @@ export function useInlineBackup(backupName: string | (() => string)) {
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { serverId, worldId, backupsState, markBackupCancelled } = serverCtx
const { serverId, worldId } = serverCtx
const isBackingUp = ref(false)
const { activeOperationByBackupId, backups, hasActiveCreate, invalidate } = useServerBackupsQueue(
computed(() => serverId),
worldId,
)
const createdBackupId = ref<string | null>(null)
const pendingCreate = ref(false)
const backupFailed = ref(false)
const backupComplete = ref(false)
const backupCancelled = ref(false)
const isCancelling = ref(false)
const createdBackupId = ref<string | null>(null)
const externalBackupInProgress = computed(() => {
for (const [id, entry] of backupsState.entries()) {
if (id !== createdBackupId.value && entry.create?.state === 'ongoing') return true
}
return false
})
// Watch backupsState for websocket progress events from Kyros
watch(
() => {
if (!createdBackupId.value) return null
return backupsState.get(createdBackupId.value)
},
(entry) => {
if (!entry?.create) return
if (entry.create.state === 'done') {
stopPolling()
isBackingUp.value = false
backupComplete.value = true
} else if (entry.create.state === 'cancelled') {
stopPolling()
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true
} else if (entry.create.state === 'failed') {
stopPolling()
isBackingUp.value = false
backupFailed.value = true
}
},
{ deep: true },
const myBackup = computed(() =>
createdBackupId.value ? backups.value.find((b) => b.id === createdBackupId.value) : undefined,
)
const myActiveOp = computed(() =>
createdBackupId.value ? activeOperationByBackupId.value.get(createdBackupId.value) : undefined,
)
// Fallback: poll the REST API in case websocket events don't arrive
let pollTimer: ReturnType<typeof setInterval> | null = null
const isBackingUp = computed(
() =>
!backupComplete.value &&
!backupFailed.value &&
!backupCancelled.value &&
(!!createdBackupId.value || pendingCreate.value),
)
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
const externalBackupInProgress = computed(() => hasActiveCreate.value && !myActiveOp.value)
async function pollBackupStatus(backupId: string) {
if (!isBackingUp.value) {
stopPolling()
return
}
try {
const backup = await client.archon.backups_v1.get(serverId, worldId.value!, backupId)
const isTerminal =
backup.status === 'done' || backup.status === 'error' || backup.status === 'timed_out'
if (isTerminal) {
stopPolling()
if (!isBackingUp.value) return
if (backup.status === 'error' || backup.status === 'timed_out') {
isBackingUp.value = false
backupFailed.value = true
} else {
isBackingUp.value = false
backupComplete.value = true
}
}
} catch {
stopPolling()
isBackingUp.value = false
backupFailed.value = true
}
}
watch(
myBackup,
(b) => {
if (!createdBackupId.value || !b) return
if (b.status === 'done') backupComplete.value = true
else if (b.status === 'error' || b.status === 'timed_out') backupFailed.value = true
},
{ immediate: true },
)
async function startBackup() {
if (!worldId.value) return
const name = typeof backupName === 'function' ? backupName() : backupName
isBackingUp.value = true
backupFailed.value = false
backupComplete.value = false
backupCancelled.value = false
isCancelling.value = false
createdBackupId.value = null
pendingCreate.value = true
try {
const { id } = await client.archon.backups_v1.create(serverId, worldId.value, { name })
const { id } = await client.archon.backups_queue_v1.create(serverId, worldId.value, { name })
createdBackupId.value = id
stopPolling()
pollTimer = setInterval(() => pollBackupStatus(id), 3000)
await invalidate()
} catch (error) {
isBackingUp.value = false
backupFailed.value = true
const message = error instanceof Error ? error.message : String(error)
const isRateLimit = message.includes('429')
addNotification({
@@ -171,6 +127,8 @@ export function useInlineBackup(backupName: string | (() => string)) {
title: 'Error creating backup',
text: isRateLimit ? "You're creating backups too fast." : message,
})
} finally {
pendingCreate.value = false
}
}
@@ -178,23 +136,19 @@ export function useInlineBackup(backupName: string | (() => string)) {
if (!worldId.value || !createdBackupId.value || !isBackingUp.value) return
isCancelling.value = true
stopPolling()
markBackupCancelled(createdBackupId.value)
try {
await client.archon.backups_v1.delete(serverId, worldId.value, createdBackupId.value)
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true
isCancelling.value = false
await invalidate()
addNotification({
type: 'info',
title: 'Backup cancelled',
text: 'The backup has been cancelled. You can create a new one or proceed without a backup.',
})
} catch {
isBackingUp.value = false
isCancelling.value = false
backupFailed.value = true
isCancelling.value = false
}
}
@@ -216,7 +170,6 @@ export function useInlineBackup(backupName: string | (() => string)) {
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
stopPolling()
})
onBeforeRouteLeave(() => {
@@ -17,16 +17,12 @@ import {
ShareIcon,
TextCursorInputIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -53,6 +49,15 @@ import type { ContentCardTableItem, ContentItem } from './types'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentPageLayout')
const props = withDefaults(
defineProps<{
bottomPadding?: boolean
}>(),
{
bottomPadding: true,
},
)
const messages = defineMessages({
loadingContent: {
id: 'content.page-layout.loading',
@@ -134,18 +139,10 @@ const messages = defineMessages({
id: 'content.page-layout.share.label',
defaultMessage: 'Share',
},
uploadingFiles: {
id: 'content.page-layout.uploading-files',
defaultMessage: 'Uploading files ({completed}/{total})',
},
sortByLabel: {
id: 'content.page-layout.sort.label',
defaultMessage: 'Sort by {mode}',
},
busyDescription: {
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
pleaseWait: {
id: 'content.page-layout.please-wait',
defaultMessage: 'Please wait',
@@ -154,12 +151,6 @@ const messages = defineMessages({
const ctx = injectContentManager()
const uploadOverallProgress = computed(() => {
const state = ctx.uploadState?.value
if (!state || !state.isUploading || state.totalFiles === 0) return 0
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
})
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
const sortMode = ref<SortMode>('alphabetical-asc')
@@ -502,7 +493,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</script>
<template>
<div class="flex flex-col gap-4 pb-6">
<div class="flex flex-col gap-4" :class="{ 'pb-6': props.bottomPadding }">
<template v-if="!ctx.loading.value">
<div
v-if="ctx.error.value"
@@ -518,11 +509,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</div>
<template v-else>
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
<template #header>{{ ctx.busyMessage.value }}</template>
{{ formatMessage(messages.busyDescription) }}
</Admonition>
<ContentModpackCard
v-if="ctx.modpack.value"
:project="ctx.modpack.value.project"
@@ -550,43 +536,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<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"
aria-live="polite"
>
<Admonition
v-if="ctx.uploadState?.value?.isUploading"
type="info"
show-actions-underneath
>
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
formatMessage(messages.uploadingFiles, {
completed: ctx.uploadState?.value?.completedFiles ?? 0,
total: ctx.uploadState?.value?.totalFiles ?? 0,
})
}}
</template>
<span class="text-secondary">
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template #actions>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
@@ -25,8 +25,6 @@ export interface ContentModpackData {
disabledText?: string
}
export type { UploadState } from '@modrinth/api-client'
export interface ContentManagerContext {
// Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
@@ -79,9 +77,6 @@ export interface ContentManagerContext {
// Share support (optional — when undefined, share button becomes hidden entirely)
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
// Upload progress (optional)
uploadState?: Ref<UploadState> | ComputedRef<UploadState>
// Bulk operation guard — set by layout, checked by providers to suppress refreshes
isBulkOperating?: Ref<boolean>
@@ -1,113 +0,0 @@
<template>
<TransitionGroup
name="fs-op"
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-for="op in activeOperations"
:key="`fs-op-${op.op}-${op.src}`"
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
class="mb-4"
>
<template #icon="{ iconClass }">
<PackageOpenIcon :class="iconClass" />
</template>
<template #header>
{{
formatMessage(messages.extracting, {
source: op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : op.src,
})
}}
<span v-if="op.state === 'done'" class="font-normal text-green">
{{ formatMessage(commonMessages.doneLabel) }}</span
>
<span v-else-if="op.state?.startsWith('fail')" class="font-normal text-red">
{{ formatMessage(messages.failed) }}</span
>
</template>
<span class="text-secondary">
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
})
}}
<template v-if="'current_file' in op && op.current_file">
{{ op.current_file?.split('/')?.pop() }}
</template>
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled
v-if="op.state !== 'done' && !op.state?.startsWith('fail')"
type="outlined"
color="blue"
>
<button class="!border" @click="ctx.dismissOperation(op.id!, 'cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="op.state === 'done' || op.state?.startsWith('fail')"
circular
type="transparent"
hover-color-fill="background"
:color="op.state === 'done' ? 'green' : 'red'"
>
<button @click="ctx.dismissOperation(op.id!, 'dismiss')">
<XIcon />
</button>
</ButtonStyled>
</template>
<template #progress>
<ProgressBar
:progress="'progress' in op ? (op.progress ?? 0) : 0"
:max="1"
:color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
full-width
/>
</template>
</Admonition>
</TransitionGroup>
</template>
<script setup lang="ts">
import { PackageOpenIcon, XIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
extracting: {
id: 'files.operations.extracting',
defaultMessage: 'Extracting {source}',
},
modpackFromUrl: {
id: 'files.operations.modpack-from-url',
defaultMessage: 'modpack from URL',
},
failed: {
id: 'files.operations.failed',
defaultMessage: 'Failed',
},
extracted: {
id: 'files.operations.extracted',
defaultMessage: '{size} extracted',
},
})
const ctx = injectModrinthServerContext()
const activeOperations = ctx.activeOperations
</script>
@@ -32,10 +32,6 @@
>
</FileContextMenu>
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
<template #header>{{ ctx.busyWarning.value }}</template>
{{ formatMessage(messages.busyWarning) }}
</Admonition>
<div class="relative flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-4">
<FileNavbar
@@ -210,7 +206,6 @@ import {
import type { Component } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -244,10 +239,6 @@ import type { FileContextMenuOption, FileItem } from './types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
busyWarning: {
id: 'files.layout.busy-warning',
defaultMessage: 'File operations are disabled while the operation is in progress.',
},
emptyFolderTitle: {
id: 'files.layout.empty-folder-title',
defaultMessage: 'This folder is empty',