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:
@@ -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',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
type BackupQueueBackup = Archon.BackupsQueue.v1.BackupQueueBackup
|
||||
|
||||
export function useBackupsSelection(
|
||||
visibleBackups: Ref<BackupQueueBackup[]>,
|
||||
displayOrderedBackups: ComputedRef<BackupQueueBackup[]>,
|
||||
) {
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
watch(visibleBackups, () => {
|
||||
const ids = new Set(visibleBackups.value.map((b) => b.id))
|
||||
const next = new Set<string>()
|
||||
for (const id of selectedIds.value) {
|
||||
if (ids.has(id)) next.add(id)
|
||||
}
|
||||
if (next.size !== selectedIds.value.size) {
|
||||
selectedIds.value = next
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds.value = new Set(visibleBackups.value.map((b) => b.id))
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) deselectAll()
|
||||
else selectAll()
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
visibleBackups.value.length > 0 &&
|
||||
visibleBackups.value.every((b) => selectedIds.value.has(b.id)),
|
||||
)
|
||||
|
||||
const someSelected = computed(() => {
|
||||
const vis = visibleBackups.value
|
||||
if (vis.length === 0) return false
|
||||
let n = 0
|
||||
for (const b of vis) {
|
||||
if (selectedIds.value.has(b.id)) n++
|
||||
}
|
||||
return n > 0 && n < vis.length
|
||||
})
|
||||
|
||||
const selectedBackups = computed(() =>
|
||||
displayOrderedBackups.value.filter((b) => selectedIds.value.has(b.id)),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
selectedBackups,
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,33 @@
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<ReadyTransition :pending="backupsReadyPending">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupCreateModal ref="createBackupModal" :backups="completedBackups" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="completedBackups" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
||||
<BackupDeleteModal
|
||||
ref="deleteBackupModal"
|
||||
@delete="deleteBackup"
|
||||
@bulk-delete="bulkDelete"
|
||||
/>
|
||||
|
||||
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
||||
<div
|
||||
v-if="completedBackups.length"
|
||||
class="mb-2 flex flex-wrap items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-4">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:label="formatMessage(messages.selectAll)"
|
||||
class="shrink-0"
|
||||
label-class="text-secondary font-semibold"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
<div class="hidden h-6 w-px bg-surface-5 sm:block" />
|
||||
<FilterPills v-model="selectedFilters" :options="filterPillOptions">
|
||||
<template #all>{{ formatMessage(commonMessages.allProjectType) }}</template>
|
||||
</FilterPills>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
@@ -42,80 +62,157 @@
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
{{ formatMessage(messages.createBackup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<template v-if="backupsData">
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="groupedBackups.length === 0"
|
||||
key="empty"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<EmptyState
|
||||
type="empty-inbox"
|
||||
heading="No backups yet"
|
||||
description="Create your first backup"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
class="w-min mx-auto"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||
<template v-for="group in groupedBackups" :key="group.label">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
||||
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex w-5 justify-center">
|
||||
<div class="h-full w-px bg-surface-5" />
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
||||
<BackupItem
|
||||
v-for="backup in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-copy-id-action="showCopyIdAction"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation
|
||||
? deleteBackup(backup)
|
||||
: deleteBackupModal?.show(backup)
|
||||
"
|
||||
@retry="() => retryBackup(backup.id)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<div
|
||||
v-if="groupedBackups.length === 0"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<EmptyState
|
||||
v-if="completedBackups.length === 0"
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.emptyHeading)"
|
||||
:description="formatMessage(messages.emptyDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
class="mx-auto w-min"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.createBackup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.filteredEmptyHeading)"
|
||||
:description="formatMessage(messages.filteredEmptyDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="clearBackupFilters">
|
||||
{{ formatMessage(messages.clearFilters) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<template v-for="group in groupedBackups" :key="group.label">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex w-5 shrink-0 items-center justify-center">
|
||||
<component :is="group.icon" v-if="group.icon" class="size-5" />
|
||||
</div>
|
||||
<span class="text-lg font-semibold leading-5 text-contrast">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="div" class="flex flex-col">
|
||||
<div
|
||||
v-for="(backup, backupIndex) in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<div class="flex w-5 flex-col items-center">
|
||||
<div
|
||||
class="w-px flex-1 bg-surface-5"
|
||||
:class="{ '-mt-1.5': backupIndex === 0 }"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="selectedIds.has(backup.id)"
|
||||
:description="formatMessage(messages.selectBackupAria, { name: backup.name })"
|
||||
class="shrink-0"
|
||||
@update:model-value="toggleSelection(backup.id)"
|
||||
/>
|
||||
<div class="w-px flex-1 bg-surface-5" />
|
||||
</div>
|
||||
<BackupItem
|
||||
class="my-1.5 min-w-0 flex-1"
|
||||
:backup="backup"
|
||||
:selected="selectedIds.has(backup.id)"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-copy-id-action="showCopyIdAction"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar
|
||||
:shown="selectedIds.size > 0 || isBulkOperating"
|
||||
:aria-label="
|
||||
formatMessage(messages.bulkBarAriaLabel, {
|
||||
count: isBulkOperating ? bulkTotal : selectedIds.size,
|
||||
})
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-contrast">
|
||||
{{
|
||||
formatMessage(messages.selectedCount, {
|
||||
count: isBulkOperating ? bulkTotal : selectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isBulkOperating"
|
||||
:class="{ 'pointer-events-none opacity-60': isBulkOperating }"
|
||||
@click="deselectAll"
|
||||
>
|
||||
{{ formatMessage(commonMessages.clearButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<ButtonStyled type="transparent" color="red" hover-color-fill="background">
|
||||
<button type="button" @click="confirmBulkDelete">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else class="ml-auto flex items-center" aria-live="polite">
|
||||
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-secondary">
|
||||
{{ formatMessage(messages.bulkDeleting, { total: bulkTotal }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
|
||||
<div
|
||||
class="animate-indeterminate h-full rounded-l-full bg-brand"
|
||||
role="progressbar"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="bulkTotal"
|
||||
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
@@ -142,35 +239,102 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import FilterPills, { type FilterPillOption } from '#ui/components/base/FilterPills.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
|
||||
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
|
||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { useBackupsSelection } from './backups-selection'
|
||||
|
||||
const messages = defineMessages({
|
||||
selectAll: {
|
||||
id: 'servers.backups.toolbar.select-all',
|
||||
defaultMessage: 'Select all',
|
||||
},
|
||||
selectBackupAria: {
|
||||
id: 'servers.backups.select-backup-aria',
|
||||
defaultMessage: 'Select backup {name}',
|
||||
},
|
||||
filterManual: {
|
||||
id: 'servers.backups.toolbar.filter-manual',
|
||||
defaultMessage: 'Manual',
|
||||
},
|
||||
filterAuto: {
|
||||
id: 'servers.backups.toolbar.filter-auto',
|
||||
defaultMessage: 'Auto',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'servers.backups.bulk-bar.selected-count',
|
||||
defaultMessage: '{count, plural, one {# backup selected} other {# backups selected}}',
|
||||
},
|
||||
bulkBarAriaLabel: {
|
||||
id: 'servers.backups.bulk-bar.aria-label',
|
||||
defaultMessage:
|
||||
'{count, plural, one {Bulk actions for one selected backup} other {Bulk actions for # selected backups}}',
|
||||
},
|
||||
createBackup: {
|
||||
id: 'servers.backups.toolbar.create-backup',
|
||||
defaultMessage: 'Create backup',
|
||||
},
|
||||
emptyHeading: {
|
||||
id: 'servers.backups.empty.heading',
|
||||
defaultMessage: 'No backups yet',
|
||||
},
|
||||
emptyDescription: {
|
||||
id: 'servers.backups.empty.description',
|
||||
defaultMessage: 'Create your first backup',
|
||||
},
|
||||
filteredEmptyHeading: {
|
||||
id: 'servers.backups.filtered-empty.heading',
|
||||
defaultMessage: 'No backups match',
|
||||
},
|
||||
filteredEmptyDescription: {
|
||||
id: 'servers.backups.filtered-empty.description',
|
||||
defaultMessage: 'Try a different filter or clear filters to see all backups.',
|
||||
},
|
||||
clearFilters: {
|
||||
id: 'servers.backups.filtered-empty.clear-filters',
|
||||
defaultMessage: 'Clear filters',
|
||||
},
|
||||
bulkDeleting: {
|
||||
id: 'servers.backups.bulk-bar.deleting',
|
||||
defaultMessage: 'Deleting {total, plural, one {# backup} other {# backups}}...',
|
||||
},
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const filterPillOptions = computed<FilterPillOption[]>(() => [
|
||||
{ id: 'manual', label: formatMessage(messages.filterManual) },
|
||||
{ id: 'auto', label: formatMessage(messages.filterAuto) },
|
||||
])
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { server, worldId, backupsState, markBackupCancelled, busyReasons } =
|
||||
injectModrinthServerContext()
|
||||
const { server, worldId, busyReasons } = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
isServerRunning: boolean
|
||||
@@ -183,81 +347,82 @@ const serverId = route.params.id as string
|
||||
|
||||
defineEmits(['onDownload'])
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', serverId]
|
||||
const {
|
||||
data: backupsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: backupsQueryKey,
|
||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
const { backups, invalidate, hasActiveCreate, hasActiveRestore, query } = useServerBackupsQueue(
|
||||
computed(() => serverId),
|
||||
worldId,
|
||||
)
|
||||
|
||||
const error = computed(() => {
|
||||
const err = query.error.value
|
||||
return err instanceof Error ? err : err ? new Error(String(err)) : null
|
||||
})
|
||||
const refetch = () => query.refetch()
|
||||
|
||||
/** Until world exists we cannot fetch; `isLoading` is false while the query is disabled, which would flash empty state. */
|
||||
const backupsReadyPending = computed(
|
||||
() => !worldId.value || (query.data.value === undefined && !query.error.value),
|
||||
)
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
|
||||
const completedBackups = computed(() => backups.value.filter((backup) => backup.status === 'done'))
|
||||
|
||||
const filteredBackups = computed(() => {
|
||||
const f = selectedFilters.value
|
||||
if (f.length === 0 || f.length === 2) {
|
||||
return completedBackups.value
|
||||
}
|
||||
const wantAuto = f.includes('auto')
|
||||
return completedBackups.value.filter((b) => b.automated === wantAuto)
|
||||
})
|
||||
|
||||
const backupsReadyPending = useReadyState({ isLoading, data: backupsData })
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
/** Completed backups with a snapshot: queue API schedules deletion. */
|
||||
const deleteQueueMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
|
||||
onSuccess: (_data, backupId) => {
|
||||
markBackupCancelled(backupId)
|
||||
backupsState.delete(backupId)
|
||||
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
client.archon.backups_queue_v1.delete(serverId, worldId.value!, backupId),
|
||||
onSuccess: async () => {
|
||||
await invalidate()
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
},
|
||||
})
|
||||
|
||||
const retryMutation = useMutation({
|
||||
/** In-progress / incomplete backups: legacy cancel + delete path. */
|
||||
const deleteLegacyMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
client.archon.backups_v1.retry(serverId, worldId.value!, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
|
||||
onSuccess: async () => {
|
||||
await invalidate()
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
},
|
||||
})
|
||||
|
||||
const backups = computed(() => {
|
||||
if (!backupsData.value) return []
|
||||
|
||||
const merged = backupsData.value.map((backup) => {
|
||||
const progressState = backupsState.get(backup.id)
|
||||
if (progressState) {
|
||||
const hasOngoingTask = Object.values(progressState).some((task) => task?.state === 'ongoing')
|
||||
const hasCompletedTask = Object.values(progressState).some((task) => task?.state === 'done')
|
||||
|
||||
return {
|
||||
...backup,
|
||||
task: {
|
||||
...backup.task,
|
||||
...progressState,
|
||||
},
|
||||
status: hasOngoingTask
|
||||
? ('in_progress' as const)
|
||||
: hasCompletedTask
|
||||
? ('done' as const)
|
||||
: backup.status,
|
||||
ongoing: hasOngoingTask || (backup.ongoing && !hasCompletedTask),
|
||||
}
|
||||
}
|
||||
return backup
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
/** Bulk delete via queue API — handles both completed and in-progress backups (cancels the latter). */
|
||||
const deleteManyMutation = useMutation({
|
||||
mutationFn: (backupIds: string[]) =>
|
||||
client.archon.backups_queue_v1.deleteMany(serverId, worldId.value!, backupIds),
|
||||
onSuccess: async () => {
|
||||
await invalidate()
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
},
|
||||
})
|
||||
|
||||
type BackupGroup = {
|
||||
label: string
|
||||
icon: Component | null
|
||||
backups: Archon.Backups.v1.Backup[]
|
||||
backups: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||
}
|
||||
|
||||
const groupedBackups = computed((): BackupGroup[] => {
|
||||
if (!backups.value.length) return []
|
||||
if (!filteredBackups.value.length) return []
|
||||
|
||||
const now = dayjs()
|
||||
const groups: BackupGroup[] = []
|
||||
|
||||
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => {
|
||||
const addToGroup = (
|
||||
label: string,
|
||||
icon: Component | null,
|
||||
backup: Archon.BackupsQueue.v1.BackupQueueBackup,
|
||||
) => {
|
||||
let group = groups.find((g) => g.label === label)
|
||||
if (!group) {
|
||||
group = { label, icon, backups: [] }
|
||||
@@ -266,7 +431,7 @@ const groupedBackups = computed((): BackupGroup[] => {
|
||||
group.backups.push(backup)
|
||||
}
|
||||
|
||||
for (const backup of backups.value) {
|
||||
for (const backup of filteredBackups.value) {
|
||||
const created = dayjs(backup.created_at)
|
||||
const diffMinutes = now.diff(created, 'minute')
|
||||
const isToday = created.isSame(now, 'day')
|
||||
@@ -289,6 +454,20 @@ const groupedBackups = computed((): BackupGroup[] => {
|
||||
return groups
|
||||
})
|
||||
|
||||
const displayOrderedBackups = computed(() => groupedBackups.value.flatMap((g) => g.backups))
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
toggleSelection,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
selectedBackups,
|
||||
} = useBackupsSelection(filteredBackups, displayOrderedBackups)
|
||||
|
||||
const { isBulkOperating, bulkTotal } = useBulkOperation()
|
||||
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
|
||||
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
|
||||
@@ -302,13 +481,16 @@ const backupRestoreDisabled = computed(() => {
|
||||
if (busyReasons.value.length > 0) {
|
||||
return formatMessage(busyReasons.value[0].reason)
|
||||
}
|
||||
if (hasActiveCreate.value || hasActiveRestore.value) {
|
||||
return 'A backup operation is already queued or in progress'
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const backupCreationDisabled = computed(() => {
|
||||
const quota = server.value.backup_quota
|
||||
if (quota !== undefined) {
|
||||
const usedCount = backupsData.value?.length ?? server.value.used_backup_quota ?? 0
|
||||
const usedCount = backups.value.length ?? server.value.used_backup_quota ?? 0
|
||||
if (usedCount >= quota) {
|
||||
return `All ${quota} of your backup slots are in use`
|
||||
}
|
||||
@@ -316,9 +498,8 @@ const backupCreationDisabled = computed(() => {
|
||||
if (busyReasons.value.length > 0) {
|
||||
return formatMessage(busyReasons.value[0].reason)
|
||||
}
|
||||
// also check for active backups, combining REST data with WS overlay
|
||||
if (backups.value.some((b) => b.status === 'in_progress' || b.status === 'pending')) {
|
||||
return 'A backup is already in progress'
|
||||
if (hasActiveCreate.value) {
|
||||
return 'A backup is already queued or in progress'
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
@@ -327,20 +508,46 @@ const showCreateModel = () => {
|
||||
createBackupModal.value?.show()
|
||||
}
|
||||
|
||||
function clearBackupFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
if (!selectedBackups.value.length) return
|
||||
deleteBackupModal.value?.showBulk(selectedBackups.value)
|
||||
}
|
||||
|
||||
async function bulkDelete(toRemove: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
|
||||
if (!toRemove.length) return
|
||||
|
||||
isBulkOperating.value = true
|
||||
bulkTotal.value = toRemove.length
|
||||
|
||||
try {
|
||||
await deleteManyMutation.mutateAsync(toRemove.map((b) => b.id))
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: `Failed to delete ${toRemove.length} backup${toRemove.length === 1 ? '' : 's'}`,
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
deselectAll()
|
||||
isBulkOperating.value = false
|
||||
bulkTotal.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||
}
|
||||
|
||||
const retryBackup = (backupId: string) => {
|
||||
retryMutation.mutate(backupId, {
|
||||
onError: (err) => {
|
||||
console.error('Failed to retry backup:', err)
|
||||
},
|
||||
})
|
||||
function useQueueDeleteFor(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
return backup.status === 'done'
|
||||
}
|
||||
|
||||
function deleteBackup(backup?: Archon.Backups.v1.Backup) {
|
||||
function deleteBackup(backup?: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
if (!backup) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -350,7 +557,9 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteMutation.mutate(backup.id, {
|
||||
const mutation = useQueueDeleteFor(backup) ? deleteQueueMutation : deleteLegacyMutation
|
||||
|
||||
mutation.mutate(backup.id, {
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
addNotification({
|
||||
@@ -396,6 +605,21 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
width: 20%;
|
||||
margin-left: -20%;
|
||||
}
|
||||
100% {
|
||||
width: 60%;
|
||||
margin-left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.over-the-top-download-animation {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
@@ -22,10 +21,7 @@ import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/Co
|
||||
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
||||
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
|
||||
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
|
||||
import type {
|
||||
ContentModpackData,
|
||||
UploadState,
|
||||
} from '../../../shared/content-tab/providers/content-manager'
|
||||
import type { ContentModpackData } from '../../../shared/content-tab/providers/content-manager'
|
||||
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
|
||||
import type {
|
||||
ContentItem,
|
||||
@@ -85,20 +81,9 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const leaveMessages = defineMessages({
|
||||
uploadInProgress: {
|
||||
id: 'instances.confirm-leave-modal.upload-in-progress',
|
||||
defaultMessage: 'Upload in progress',
|
||||
},
|
||||
leavePageBody: {
|
||||
id: 'instances.confirm-leave-modal.body',
|
||||
defaultMessage:
|
||||
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
|
||||
},
|
||||
})
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
|
||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const route = useRoute()
|
||||
@@ -352,57 +337,10 @@ async function handleBulkDisable(items: ContentItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
|
||||
const isUploading = computed(() => uploadState.value.isUploading)
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isUploading.value) {
|
||||
e.preventDefault()
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(isUploading, (uploading) => {
|
||||
if (uploading) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (isUploading.value) {
|
||||
const shouldLeave = (await confirmLeaveModal.value?.prompt()) ?? false
|
||||
if (shouldLeave) {
|
||||
activeUploadCancel?.()
|
||||
}
|
||||
return shouldLeave
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingModpack = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
@@ -486,7 +424,7 @@ function handleUploadFiles() {
|
||||
uploadState.value.totalBytes = p.total
|
||||
},
|
||||
})
|
||||
activeUploadCancel = () => handle.cancel()
|
||||
cancelUpload.value = () => handle.cancel()
|
||||
|
||||
try {
|
||||
await handle.promise
|
||||
@@ -500,7 +438,7 @@ function handleUploadFiles() {
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
activeUploadCancel = null
|
||||
cancelUpload.value = null
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
@@ -875,7 +813,6 @@ provideContentManager({
|
||||
},
|
||||
browse: handleBrowseContent,
|
||||
uploadFiles: handleUploadFiles,
|
||||
uploadState,
|
||||
deletionContext: 'server',
|
||||
hasUpdateSupport: true,
|
||||
updateItem: handleUpdateItem,
|
||||
@@ -911,7 +848,7 @@ provideContentManager({
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="contentReadyPending">
|
||||
<ContentPageLayout>
|
||||
<ContentPageLayout :bottom-padding="false">
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
<ModpackContentModal
|
||||
@@ -968,10 +905,4 @@ provideContentManager({
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ConfirmLeaveModal
|
||||
ref="confirmLeaveModal"
|
||||
:header="formatMessage(leaveMessages.uploadInProgress)"
|
||||
:body="formatMessage(leaveMessages.leavePageBody)"
|
||||
admonition-type="critical"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!showEmptyState"
|
||||
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
||||
>
|
||||
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
||||
@@ -131,7 +132,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
v-else-if="showEmptyState"
|
||||
key="empty"
|
||||
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
||||
>
|
||||
@@ -559,6 +560,11 @@ const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
return serverResponse.value.servers
|
||||
})
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!showServersListLoading.value && serverList.value.length === 0 && !isPollingForNewServers.value,
|
||||
)
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const fuse = computed(() => {
|
||||
|
||||
@@ -117,7 +117,12 @@ provideConsoleManager({
|
||||
},
|
||||
showCommandInput: true,
|
||||
disableCommandInput: computed(() => serverPowerState.value !== 'running'),
|
||||
loading: computed(() => !isConnected.value || isWsAuthIncorrect.value),
|
||||
loading: computed(
|
||||
() =>
|
||||
!isConnected.value ||
|
||||
modrinthServersConsole.isInitialLogHydrating.value ||
|
||||
isWsAuthIncorrect.value,
|
||||
),
|
||||
onClear: async () => {
|
||||
modrinthServersConsole.clear()
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user