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
@@ -1,12 +1,19 @@
<template>
<Admonition :type="contentError ? 'critical' : 'info'" :show-actions-underneath="!contentError">
<Admonition
:type="contentError ? 'critical' : 'info'"
:dismissible="dismissible"
:progress="progressValue"
progress-color="blue"
:waiting="isWaiting"
@dismiss="emit('dismiss')"
>
<template #icon>
<slot v-if="!contentError" name="icon">
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
</slot>
</template>
<template #header>
{{ contentError ? 'Installation error' : "We're preparing your server!" }}
{{ contentError ? 'Installation failed' : "We're preparing your server" }}
</template>
<template v-if="contentError">
{{ errorLabel }}
@@ -26,22 +33,12 @@
</div>
<template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button class="!border" @click="emit('retry')">
<button class="!border" type="button" @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
Retry
</button>
</ButtonStyled>
</template>
<template v-if="!contentError" #actions>
<ProgressBar
v-if="progress"
:progress="progress.percent"
:max="100"
color="blue"
full-width
/>
<ProgressBar v-else :progress="0" :max="1" color="blue" full-width waiting />
</template>
</Admonition>
</template>
@@ -52,7 +49,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
@@ -67,10 +63,12 @@ export interface ContentError {
const props = defineProps<{
progress?: SyncProgress | null
contentError?: ContentError | null
dismissible?: boolean
}>()
const emit = defineEmits<{
retry: []
dismiss: []
}>()
const errorLabel = computed(() => {
@@ -91,10 +89,10 @@ const errorLabel = computed(() => {
if (step === 'modpack') {
if (desc?.includes('no primary file')) {
return 'The modpack version has no downloadable file. It may have been packaged incorrectly.'
return 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.'
}
if (desc?.includes('failed to install')) {
return 'Failed to install the modpack. It may be corrupted or incompatible.'
return 'The modpack could not be installed. It may be corrupted or incompatible.'
}
}
@@ -114,6 +112,16 @@ const phaseLabel = computed(() => {
}
})
const progressValue = computed(() => {
if (props.contentError) return undefined
return props.progress ? props.progress.percent / 100 : 0
})
const isWaiting = computed(() => {
if (props.contentError) return false
return !props.progress || props.progress.percent <= 0
})
const tickerMessages = [
'Organizing files...',
'Downloading mods...',
@@ -0,0 +1,293 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import type { MessageDescriptor } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
export type AdmonitionDisplayState = 'ongoing' | Archon.BackupsQueue.v1.BackupQueueState
export type BackupAdmonitionEntry = {
key: string
backupId: string
type: 'create' | 'restore'
state: AdmonitionDisplayState
progress: number
operationId: number | null
syntheticLegacy: boolean
name?: string
timestamp?: string
error?: string | null
}
defineProps<{
item: BackupAdmonitionEntry
dismissible: boolean
cancelling: boolean
}>()
defineEmits<{
dismiss: []
retry: []
cancel: []
}>()
const { formatMessage } = useVIntl()
type UiPhase = 'queued' | 'in_progress' | 'failed' | 'timed_out' | 'cancelled' | 'completed'
function resolveUiPhase(item: BackupAdmonitionEntry): UiPhase | null {
switch (item.state) {
case 'pending':
return 'queued'
case 'ongoing':
return 'in_progress'
case 'failed':
case 'timed_out':
case 'cancelled':
case 'completed':
return item.state
default:
return null
}
}
function getAdmonitionType(state: AdmonitionDisplayState): 'info' | 'critical' | 'success' {
if (state === 'failed' || state === 'timed_out') return 'critical'
if (state === 'completed') return 'success'
return 'info'
}
function getIcon(state: AdmonitionDisplayState) {
if (state === 'failed' || state === 'timed_out') return TriangleAlertIcon
if (state === 'completed') return CheckCircleIcon
return InfoIcon
}
function isQueued(item: BackupAdmonitionEntry) {
return resolveUiPhase(item) === 'queued'
}
function isInProgress(item: BackupAdmonitionEntry) {
return resolveUiPhase(item) === 'in_progress'
}
function isTerminal(item: BackupAdmonitionEntry) {
return item.state !== 'pending' && item.state !== 'ongoing'
}
function canRetry(item: BackupAdmonitionEntry) {
return item.state === 'failed' || item.state === 'timed_out'
}
function canCancel(item: BackupAdmonitionEntry) {
return isQueued(item) || isInProgress(item)
}
function hasErrorDetail(item: BackupAdmonitionEntry) {
return !!item.error && (item.state === 'failed' || item.state === 'timed_out')
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
backupTimedOutTitle: {
id: 'servers.backups.admonition.backup-timed-out.title',
defaultMessage: 'Backup timed out',
},
backupTimedOutDescription: {
id: 'servers.backups.admonition.backup-timed-out.description',
defaultMessage:
'Creating {backupName} timed out. You can try again or contact support if the issue continues.',
},
backupCancelledTitle: {
id: 'servers.backups.admonition.backup-cancelled.title',
defaultMessage: 'Backup cancelled',
},
backupCancelledDescription: {
id: 'servers.backups.admonition.backup-cancelled.description',
defaultMessage: 'Backup {backupName} was cancelled.',
},
backupCompletedTitle: {
id: 'servers.backups.admonition.backup-completed.title',
defaultMessage: 'Backup finished',
},
backupCompletedDescription: {
id: 'servers.backups.admonition.backup-completed.description',
defaultMessage: '{backupName} finished successfully.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restore queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restore finished',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restore failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
restoreTimedOutTitle: {
id: 'servers.backups.admonition.restore-timed-out.title',
defaultMessage: 'Restore timed out',
},
restoreTimedOutDescription: {
id: 'servers.backups.admonition.restore-timed-out.description',
defaultMessage:
'Restoring from {backupName} timed out. You can try again or contact support if the issue continues.',
},
restoreCancelledTitle: {
id: 'servers.backups.admonition.restore-cancelled.title',
defaultMessage: 'Restore cancelled',
},
restoreCancelledDescription: {
id: 'servers.backups.admonition.restore-cancelled.description',
defaultMessage: 'Restoring from {backupName} was cancelled.',
},
})
const createTitles: Record<UiPhase, MessageDescriptor> = {
queued: messages.backupQueuedTitle,
in_progress: messages.creatingBackupTitle,
failed: messages.backupFailedTitle,
timed_out: messages.backupTimedOutTitle,
cancelled: messages.backupCancelledTitle,
completed: messages.backupCompletedTitle,
}
const restoreTitles: Record<UiPhase, MessageDescriptor> = {
queued: messages.restoreQueuedTitle,
in_progress: messages.restoringBackupTitle,
failed: messages.restoreFailedTitle,
timed_out: messages.restoreTimedOutTitle,
cancelled: messages.restoreCancelledTitle,
completed: messages.restoreSuccessfulTitle,
}
const createDescriptions: Record<UiPhase, MessageDescriptor> = {
queued: messages.backupQueuedDescription,
in_progress: messages.creatingBackupDescription,
failed: messages.backupFailedDescription,
timed_out: messages.backupTimedOutDescription,
cancelled: messages.backupCancelledDescription,
completed: messages.backupCompletedDescription,
}
const restoreDescriptions: Record<UiPhase, MessageDescriptor> = {
queued: messages.restoreQueuedDescription,
in_progress: messages.restoringBackupDescription,
failed: messages.restoreFailedDescription,
timed_out: messages.restoreTimedOutDescription,
cancelled: messages.restoreCancelledDescription,
completed: messages.restoreSuccessfulDescription,
}
function getTitle(item: BackupAdmonitionEntry): string {
const phase = resolveUiPhase(item)
if (phase == null) return ''
const table = item.type === 'create' ? createTitles : restoreTitles
return formatMessage(table[phase])
}
function getDescription(item: BackupAdmonitionEntry): string {
const phase = resolveUiPhase(item)
if (phase == null) return ''
const table = item.type === 'create' ? createDescriptions : restoreDescriptions
const backupName = item.name ?? formatMessage(messages.fallbackName)
return formatMessage(table[phase], { backupName })
}
</script>
<template>
<Admonition
:type="getAdmonitionType(item.state)"
:header="getTitle(item)"
:timestamp="item.timestamp"
:dismissible="dismissible && isTerminal(item)"
:progress="isInProgress(item) ? item.progress : undefined"
progress-color="blue"
:waiting="isInProgress(item) && item.progress === 0"
@dismiss="$emit('dismiss')"
>
<template #icon="{ iconClass }">
<component :is="getIcon(item.state)" :class="iconClass" />
</template>
<div class="flex flex-col gap-2">
<span>{{ getDescription(item) }}</span>
<span v-if="hasErrorDetail(item)" class="break-all font-mono text-sm text-secondary">
{{ item.error }}
</span>
</div>
<template #top-right-actions>
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
<button class="!border" type="button" @click="$emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
@@ -0,0 +1,99 @@
<template>
<Admonition
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
:dismissible="dismissible && isTerminal"
:progress="'progress' in op ? (op.progress ?? 0) : 0"
:progress-color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
@dismiss="$emit('dismiss')"
>
<template #icon="{ iconClass }">
<PackageOpenIcon :class="iconClass" />
</template>
<template #header>{{ title }}</template>
<span class="text-secondary">
<span>
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
})
}}
</span>
<span v-if="'current_file' in op && op.current_file">
. {{ formatMessage(messages.currentFile, { file: op.current_file?.split('/')?.pop() }) }}
</span>
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { PackageOpenIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
defineEmits<{ dismiss: [] }>()
const props = defineProps<{
op: FileOperation
dismissible: boolean
}>()
const { formatMessage } = useVIntl()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
extracting: {
id: 'files.operations.extracting',
defaultMessage: 'Extracting {source}',
},
extractingCompleted: {
id: 'files.operations.extracting-completed',
defaultMessage: 'Extracting {source} finished',
},
extractingFailed: {
id: 'files.operations.extracting-failed',
defaultMessage: 'Extracting {source} failed',
},
modpackFromUrl: {
id: 'files.operations.modpack-from-url',
defaultMessage: 'modpack from URL',
},
extracted: {
id: 'files.operations.extracted',
defaultMessage: '{size} extracted',
},
currentFile: {
id: 'files.operations.current-file',
defaultMessage: 'Current file: {file}',
},
})
const isTerminal = computed(() => props.op.state === 'done' || !!props.op.state?.startsWith('fail'))
const sourceName = computed(() =>
props.op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : props.op.src,
)
const title = computed(() => {
if (props.op.state === 'done') {
return formatMessage(messages.extractingCompleted, { source: sourceName.value })
}
if (props.op.state?.startsWith('fail')) {
return formatMessage(messages.extractingFailed, { source: sourceName.value })
}
return formatMessage(messages.extracting, { source: sourceName.value })
})
</script>
@@ -0,0 +1,410 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import { ServerIcon } from '#ui/components/servers/icons'
import InstallingBanner, {
type ContentError,
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition.vue'
import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
serverImage?: string
}>()
const emit = defineEmits<{
'content-retry': []
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const ctx = injectModrinthServerContext()
const route = useRoute()
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
computed(() => ctx.serverId),
ctx.worldId,
)
const messages = defineMessages({
backgroundTaskRunning: {
id: 'servers.admonitions.background-task-running',
defaultMessage: 'Background task running',
},
contentBusyBody: {
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
filesBusyBody: {
id: 'files.layout.busy-warning',
defaultMessage: 'File operations are disabled while the operation is in progress.',
},
})
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const bannerCoversInstalling = computed(
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
)
function isBackupReason(id: string) {
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
}
function isInstallingReason(id: string) {
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
}
const filteredBusyReasons = computed(() =>
ctx.busyReasons.value.filter((r) => {
if (isBackupReason(r.reason.id)) return false
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
return true
}),
)
const contentBusyHeader = computed(() =>
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
)
const filesBusyHeader = computed(() =>
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
)
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
)
watch(contentErrorKey, (key) => {
if (!key) {
dismissedContentErrorKey.value = null
}
})
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
const result: BackupAdmonitionEntry[] = []
const backupById = new Map(backups.value.map((b) => [b.id, b]))
for (const op of activeOperations.value) {
const key = `${op.backup_id}:${op.operation_type}:${op.operation_id ?? 'legacy'}`
if (dismissedIds.has(key)) continue
const backup = backupById.get(op.backup_id)
const history = backup?.history.find(
(h) =>
h.operation_type === op.operation_type &&
(h.operation_id ?? null) === (op.operation_id ?? null),
)
const rawProgress = progressFor(op.backup_id, op.operation_type) ?? 0
result.push({
key,
backupId: op.backup_id,
type: op.operation_type,
state: history?.state ?? 'ongoing',
progress: rawProgress,
operationId: op.operation_id ?? null,
syntheticLegacy: op.synthetic_legacy,
name: backup?.name,
timestamp: history?.scheduled_for ?? op.scheduled_for,
})
}
for (const backup of backups.value) {
const last = backup.history[0]
if (!last || !last.should_prompt) continue
if (last.state === 'pending' || last.state === 'ongoing') continue
const key = `${backup.id}:${last.operation_type}:${last.operation_id ?? 'legacy'}`
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
result.push({
key,
backupId: backup.id,
type: last.operation_type,
state: last.state,
progress: 0,
operationId: last.operation_id ?? null,
syntheticLegacy: last.synthetic_legacy,
name: backup.name,
timestamp: last.completed_at ?? last.scheduled_for,
error: last.error ?? null,
})
}
return result
})
type ServerAdmonitionItem = StackedAdmonitionItem & {
priority: number
sortIndex: number
} & (
| { kind: 'installing' }
| { kind: 'upload' }
| { kind: 'fs-op'; op: FileOperation }
| { kind: 'backup'; entry: BackupAdmonitionEntry }
| { kind: 'busy-content' }
| { kind: 'busy-files' }
)
const showInstallingBanner = computed(() => {
if (!ctx.server.value) return false
const installing =
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
if (!installing) return false
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
return false
return props.syncProgress?.phase !== 'Analyzing'
})
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
if (op.state === 'done') return 'success'
if (op.state?.startsWith('fail')) return 'critical'
return 'info'
}
function fsOpPriority(op: FileOperation): number {
if (op.state?.startsWith('fail')) return 1
if (op.state === 'done') return 4
if (op.state === 'queued') return 3
return 2
}
function backupType(entry: BackupAdmonitionEntry): StackedAdmonitionItem['type'] {
if (entry.state === 'failed' || entry.state === 'timed_out') return 'critical'
if (entry.state === 'completed') return 'success'
return 'info'
}
function backupPriority(entry: BackupAdmonitionEntry): number {
if (entry.state === 'failed' || entry.state === 'timed_out') return 1
if (entry.state === 'ongoing') return 2
if (entry.state === 'pending') return 3
return 4
}
const stackItems = computed<ServerAdmonitionItem[]>(() => {
const out: ServerAdmonitionItem[] = []
let sortIndex = 0
if (showInstallingBanner.value) {
out.push({
id: 'installing',
type: props.contentError ? 'critical' : 'info',
dismissible: !!props.contentError,
kind: 'installing',
priority: 0,
sortIndex: sortIndex++,
})
}
if (ctx.uploadState.value.isUploading) {
out.push({
id: 'upload-active',
type: 'info',
dismissible: false,
kind: 'upload',
priority: 2,
sortIndex: sortIndex++,
})
}
for (const op of ctx.activeOperations.value) {
out.push({
id: op.id ? `fs-op-${op.id}` : `fs-op-${op.op}-${op.src}`,
type: fsOpType(op),
dismissible: !!op.id && (op.state === 'done' || !!op.state?.startsWith('fail')),
kind: 'fs-op',
op,
priority: fsOpPriority(op),
sortIndex: sortIndex++,
})
}
for (const entry of backupAdmonitionEntries.value) {
out.push({
id: `backup-${entry.key}`,
type: backupType(entry),
dismissible: entry.state !== 'pending' && entry.state !== 'ongoing',
kind: 'backup',
entry,
priority: backupPriority(entry),
sortIndex: sortIndex++,
})
}
if (contentBusyHeader.value) {
const p = isOnContentTab.value ? 0 : 5
out.push({
id: 'busy-content',
type: 'warning',
dismissible: false,
kind: 'busy-content',
priority: p,
sortIndex: sortIndex++,
})
}
if (filesBusyHeader.value) {
const p = isOnFilesTab.value ? 0 : 5
out.push({
id: 'busy-files',
type: 'warning',
dismissible: false,
kind: 'busy-files',
priority: p,
sortIndex: sortIndex++,
})
}
return out.sort((a, b) => a.priority - b.priority || a.sortIndex - b.sortIndex)
})
const hasBulkDismissableItems = computed(() => stackItems.value.some((it) => it.dismissible))
async function onBackupDismiss(item: BackupAdmonitionEntry) {
dismissedIds.add(item.key)
if (item.syntheticLegacy || item.operationId == null) {
await invalidate()
return
}
try {
if (item.type === 'create') {
await client.archon.backups_queue_v1.ackCreate(
ctx.serverId,
ctx.worldId.value!,
item.operationId,
)
} else {
await client.archon.backups_queue_v1.ackRestore(
ctx.serverId,
ctx.worldId.value!,
item.operationId,
)
}
} catch (err) {
dismissedIds.delete(item.key)
console.error('Failed to acknowledge backup operation', err)
} finally {
await invalidate()
}
}
async function onBackupCancel(item: BackupAdmonitionEntry) {
if (cancellingIds.has(item.key)) return
cancellingIds.add(item.key)
try {
await client.archon.backups_v1.delete(ctx.serverId, ctx.worldId.value!, item.backupId)
await invalidate()
} catch (err) {
cancellingIds.delete(item.key)
throw err
}
}
async function onBackupRetry(item: BackupAdmonitionEntry) {
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
dismissedIds.add(item.key)
await invalidate()
}
async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
if (!it.dismissible) continue
if (it.kind === 'installing' && props.contentError) {
onContentErrorDismiss()
} else if (it.kind === 'fs-op' && it.op.id) {
const { op } = it
if (op.state === 'done' || op.state?.startsWith('fail')) {
tasks.push(ctx.dismissOperation(it.op.id, 'dismiss'))
}
} else if (it.kind === 'backup') {
tasks.push(onBackupDismiss(it.entry))
}
}
await Promise.all(tasks)
}
function onFileOpDismiss(item: ServerAdmonitionItem) {
if (item.kind === 'fs-op' && item.op.id) {
void ctx.dismissOperation(item.op.id, 'dismiss')
}
}
function onContentErrorDismiss() {
if (contentErrorKey.value) {
dismissedContentErrorKey.value = contentErrorKey.value
}
}
</script>
<template>
<StackedAdmonitions
:items="stackItems"
:dismiss-all-enabled="hasBulkDismissableItems"
class="w-full"
@dismiss-all="onDismissAll"
>
<template #item="{ item, dismissible }">
<InstallingBanner
v-if="item.kind === 'installing'"
:progress="syncProgress"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
:op="item.op"
:dismissible="dismissible"
@dismiss="onFileOpDismiss(item)"
/>
<BackupAdmonition
v-else-if="item.kind === 'backup'"
:item="item.entry"
:dismissible="dismissible"
:cancelling="cancellingIds.has(item.entry.key)"
@dismiss="onBackupDismiss(item.entry)"
@cancel="onBackupCancel(item.entry)"
@retry="onBackupRetry(item.entry)"
/>
<Admonition
v-else-if="item.kind === 'busy-content'"
type="warning"
:header="formatMessage(messages.backgroundTaskRunning)"
>
{{ formatMessage(messages.contentBusyBody) }}
</Admonition>
<Admonition
v-else-if="item.kind === 'busy-files'"
type="warning"
:header="formatMessage(messages.backgroundTaskRunning)"
>
{{ formatMessage(messages.filesBusyBody) }}
</Admonition>
</template>
</StackedAdmonitions>
</template>
@@ -0,0 +1,45 @@
<template>
<Admonition type="info" :progress="overallProgress" progress-color="blue">
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
state.currentFileName
? `Uploading ${state.currentFileName} (${state.completedFiles}/${state.totalFiles})`
: `Uploading files (${state.completedFiles}/${state.totalFiles})`
}}
</template>
<span class="text-secondary">
{{ formatBytes(state.uploadedBytes) }} / {{ formatBytes(state.totalBytes) }} ({{
Math.round(overallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { injectModrinthServerContext } from '#ui/providers'
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
const cancelUpload = computed(() => ctx.cancelUpload.value)
const overallProgress = computed(() => {
const s = state.value
if (!s.isUploading || s.totalFiles === 0) return 0
return Math.min((s.completedFiles + s.currentFileProgress) / s.totalFiles, 1)
})
</script>
@@ -0,0 +1,5 @@
export type { BackupAdmonitionEntry } from './BackupAdmonition.vue'
export { default as BackupAdmonition } from './BackupAdmonition.vue'
export { default as FileOperationAdmonition } from './FileOperationAdmonition.vue'
export { default as ServerPanelAdmonitions } from './ServerPanelAdmonitions.vue'
export { default as UploadAdmonition } from './UploadAdmonition.vue'
@@ -1,6 +1,6 @@
<template>
<NewModal ref="modal" header="Create backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px] -mb-2">
<NewModal ref="modal" header="Create backup" width="500px" @show="focusInput">
<div class="flex flex-col gap-2 -mb-2">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast">Name</span>
</label>
@@ -45,9 +45,9 @@
</Transition>
</div>
<template #actions>
<div class="w-full flex flex-row gap-2 justify-end">
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="hideModal">
<button class="!border !border-surface-4" @click="hideModal">
<XIcon />
Cancel
</button>
@@ -84,14 +84,14 @@ const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const createMutation = useMutation({
mutationFn: (name: string) =>
client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
@@ -1,28 +1,75 @@
<template>
<NewModal ref="modal" header="Delete backup" fade="danger">
<div class="flex flex-col gap-6 max-w-[400px]">
<Admonition type="critical" header="Delete warning">
This backup will be permanently deleted. This action cannot be undone.
<NewModal
ref="modal"
:header="formatMessage(messages.header, { count })"
fade="danger"
width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count }) }}
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
<div v-if="displayBackups.length" class="flex min-w-0 flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.backupsLabel, { count }) }}
</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="backupListRef"
class="flex max-h-[240px] flex-col gap-2 overflow-y-auto"
@scroll="checkScrollState"
>
<BackupItem
v-for="backup in displayBackups"
:key="backup.id"
:backup="backup"
preview
class="!bg-surface-2 !shadow-none"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteBackup">
<button @click="confirmDelete">
<TrashIcon />
Delete backup
{{ formatMessage(messages.confirm, { count }) }}
</button>
</ButtonStyled>
</div>
@@ -33,31 +80,86 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { useScrollIndicator } from '../../../composables/scroll-indicator'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
(e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup>()
const messages = defineMessages({
header: {
id: 'servers.backups.delete-modal.header',
defaultMessage: 'Delete {count, plural, one {backup} other {backups}}',
},
admonitionHeader: {
id: 'servers.backups.delete-modal.admonition-header',
defaultMessage: 'Deletion warning',
},
admonitionBody: {
id: 'servers.backups.delete-modal.admonition-body',
defaultMessage:
'Once deleted, {count, plural, one {this backup cannot} other {these backups cannot}} be recovered. Deletion is permanent.',
},
confirm: {
id: 'servers.backups.delete-modal.confirm',
defaultMessage: 'Delete {count, plural, one {backup} other {# backups}}',
},
backupsLabel: {
id: 'servers.backups.delete-modal.backups-label',
defaultMessage: '{count, plural, one {Backup} other {Backups ({count})}}',
},
})
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
const modal = ref<InstanceType<typeof NewModal>>()
const backupListRef = ref<HTMLElement | null>(null)
const singleBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup>()
const bulkBackups = ref<Archon.BackupsQueue.v1.BackupQueueBackup[]>([])
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(backupListRef)
const isBulk = computed(() => bulkBackups.value.length > 0)
const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
const displayBackups = computed(() =>
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
singleBackup.value = backup
bulkBackups.value = []
modal.value?.show()
nextTick(() => forceCheck())
}
function deleteBackup() {
function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
singleBackup.value = undefined
bulkBackups.value = [...backups]
modal.value?.show()
nextTick(() => forceCheck())
}
function confirmDelete() {
modal.value?.hide()
emit('delete', currentBackup.value)
if (isBulk.value) {
emit('bulk-delete', bulkBackups.value)
bulkBackups.value = []
} else {
emit('delete', singleBackup.value)
}
}
defineExpose({
show,
showBulk,
})
</script>
@@ -2,20 +2,19 @@
import type { Archon } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
ClockIcon,
DownloadIcon,
EditIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
ShieldIcon,
TrashIcon,
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import { commonMessages, truncatedTooltip } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
@@ -26,19 +25,20 @@ const formatDateTime = useFormatDateTime({
})
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'retry'): void
(e: 'download' | 'rename' | 'restore'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Archon.Backups.v1.Backup
backup: Archon.BackupsQueue.v1.BackupQueueBackup
preview?: boolean
kyrosUrl?: string
jwt?: string
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
selected?: boolean
}>(),
{
preview: false,
@@ -47,45 +47,15 @@ const props = withDefaults(
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
selected: false,
},
)
const failedToCreate = computed(
() => props.backup.status === 'error' || props.backup.status === 'timed_out',
)
const inactiveStates = ['failed', 'cancelled', 'done']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return true
}
if (
(props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
!props.backup.task?.restore
) {
return true
}
return false
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return true
}
return false
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const activeOperation = computed(() => creating.value || restoring.value)
const nameRef = ref<HTMLElement | null>(null)
const backupIcon = computed(() => {
if (props.backup.automated) {
return ClockIcon
return ShieldIcon
}
return UserRoundIcon
})
@@ -100,29 +70,25 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
})
}
if (!activeOperation.value) {
if (options.length > 0) {
options.push({ divider: true })
}
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
if (options.length > 0) {
options.push({ divider: true })
}
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
options.push({ id: 'rename', action: () => emit('rename') })
if (!activeOperation.value) {
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
})
}
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
})
return options
})
@@ -131,13 +97,6 @@ async function copyId() {
await navigator.clipboard.writeText(props.backup.id)
}
// TODO: Uncomment when API supports size field
// const formatBytes = (bytes?: number) => {
// if (!bytes) return ''
// const mb = bytes / (1024 * 1024)
// return `${mb.toFixed(0)} MiB`
// }
const messages = defineMessages({
restore: {
id: 'servers.backups.item.restore',
@@ -147,14 +106,6 @@ const messages = defineMessages({
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
auto: {
id: 'servers.backups.item.auto',
defaultMessage: 'Auto',
@@ -171,78 +122,67 @@ const messages = defineMessages({
</script>
<template>
<div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="
preview
? 'grid-cols-1'
: 'grid-cols-[auto_1fr_auto] md:grid-cols-[minmax(0,1fr)_400px_minmax(0,1fr)]'
"
class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
:class="props.selected ? 'border-brand-green' : 'border-transparent'"
>
<div class="flex flex-row gap-4 items-center">
<div class="flex min-w-0 flex-1 items-center gap-4">
<!-- Icon tile -->
<div
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border-solid border-[1px] border-surface-5 bg-surface-4 md:size-16"
class="flex shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5 bg-surface-4"
:class="preview ? 'size-10' : 'size-14'"
>
<component :is="backupIcon" class="size-7 text-secondary md:size-10" />
<component
:is="backupIcon"
class="text-secondary"
:class="preview ? 'size-6' : 'size-10'"
/>
</div>
<!-- Name + badge + subtitle -->
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate font-semibold text-contrast max-w-[400px]">{{ backup.name }}</span>
<div class="flex min-w-0 items-center gap-2">
<span
ref="nameRef"
v-tooltip="truncatedTooltip(nameRef, backup.name)"
class="min-w-0 truncate font-semibold text-contrast"
>
{{ backup.name }}
</span>
<span
v-if="backup.automated"
class="rounded-full border-solid border-[1px] border-surface-5 bg-surface-4 px-2.5 py-1 text-sm text-secondary"
class="shrink-0 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-secondary"
>
{{ formatMessage(messages.auto) }}
</span>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<div class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<template v-if="preview">
<span>{{ formatDateTime(backup.created_at) }}</span>
</template>
<template v-else-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" />
<span class="text-red">
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</template>
<template v-else>
<!-- TODO: Uncomment when API supports creator_id field -->
<!-- <template v-if="backup.creator_id && backup.creator_id !== 'auto'">
<Avatar ... class="size-6 rounded-full" />
<span>{{ creatorName }}</span>
</template>
<template v-else> -->
<span>
{{
formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup)
}}
</span>
<!-- </template> -->
</template>
</div>
</div>
</div>
<div
v-if="!preview"
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
>
<span class="w-full font-medium text-contrast md:text-center">
{{ formatDateTime(backup.created_at) }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
<!-- Date (middle column) -->
<div v-if="!preview" class="flex shrink-0 items-center">
<span class="whitespace-nowrap font-medium text-contrast">{{
formatDateTime(backup.created_at)
}}</span>
</div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
<!-- Right side actions -->
<div v-if="!preview" class="flex min-w-0 flex-1 items-center justify-end gap-2">
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border-[1px]"
class="!border"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
@@ -1,396 +0,0 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', serverId]
const { data: backupsList } = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
enabled: computed(() => !!worldId.value),
})
interface TerminalEntry {
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
backupName?: string
createdAt?: string
}
interface AdmonitionEntry {
key: string
backupId: string
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
name?: string
createdAt?: string
}
const terminalEntries = reactive(new Map<string, TerminalEntry>())
const dismissedIds = reactive(new Set<string>())
function findBackup(backupId: string) {
return backupsList.value?.find((b) => b.id === backupId)
}
watch(
() => [...backupsState.entries()] as [string, BackupProgressEntry][],
(entries) => {
for (const [id, entry] of entries) {
const backup = findBackup(id)
if (entry.create?.state === 'failed') {
terminalEntries.set(`${id}:create`, {
type: 'create',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'done') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'done',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'failed') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
}
},
{ deep: true },
)
const admonitions = computed<AdmonitionEntry[]>(() => {
const result: AdmonitionEntry[] = []
const seenIds = new Set<string>()
for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id)
seenIds.add(id)
if (entry.create && entry.create.state === 'ongoing') {
const key = `${id}:create`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: id,
type: 'create',
state: entry.create.state,
progress: entry.create.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
if (entry.restore && entry.restore.state === 'ongoing') {
const key = `${id}:restore`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: id,
type: 'restore',
state: entry.restore.state,
progress: entry.restore.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
}
if (backupsList.value) {
for (const backup of backupsList.value) {
if (seenIds.has(backup.id)) continue
if (backup.status === 'pending' || backup.status === 'in_progress') {
const key = `${backup.id}:create`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: backup.id,
type: 'create',
state: 'ongoing',
progress: 0,
name: backup.name,
createdAt: backup.created_at,
})
}
}
}
}
for (const [key, entry] of terminalEntries.entries()) {
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
const backupId = key.split(':')[0]
const backup = findBackup(backupId)
result.push({
key,
backupId,
type: entry.type,
state: entry.state,
progress: entry.state === 'done' ? 1 : 0,
name: backup?.name ?? entry.backupName,
createdAt: backup?.created_at ?? entry.createdAt,
})
}
return result
})
function handleCancel(backupId: string) {
client.archon.backups_v1.delete(serverId, worldId.value!, backupId).then(() => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleRetry(backupId: string, key: string) {
client.archon.backups_v1.retry(serverId, worldId.value!, backupId).then(() => {
terminalEntries.delete(key)
dismissedIds.delete(key)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleDismiss(key: string) {
dismissedIds.add(key)
terminalEntries.delete(key)
}
function getAdmonitionType(state: Archon.Backups.v1.BackupState): 'info' | 'critical' | 'success' {
if (state === 'failed') return 'critical'
if (state === 'done') return 'success'
return 'info'
}
function getIcon(state: Archon.Backups.v1.BackupState) {
if (state === 'failed') return TriangleAlertIcon
if (state === 'done') return CheckCircleIcon
return InfoIcon
}
function getButtonColor(state: Archon.Backups.v1.BackupState): 'red' | 'green' | 'blue' {
if (state === 'failed') return 'red'
if (state === 'done') return 'green'
return 'blue'
}
function isQueued(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress === 0
}
function isInProgress(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress > 0
}
function getTitle(item: AdmonitionEntry) {
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.creatingBackupTitle)
if (item.state === 'failed') return formatMessage(messages.backupFailedTitle)
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.restoringBackupTitle)
if (item.state === 'done') return formatMessage(messages.restoreSuccessfulTitle)
if (item.state === 'failed') return formatMessage(messages.restoreFailedTitle)
return ''
}
function getDescription(item: AdmonitionEntry) {
const backupName = item.name ?? formatMessage(messages.fallbackName)
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.creatingBackupDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.backupFailedDescription, { backupName })
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.restoringBackupDescription, { backupName })
if (item.state === 'done')
return formatMessage(messages.restoreSuccessfulDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.restoreFailedDescription, { backupName })
return ''
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
<TransitionGroup
v-if="admonitions.length > 0"
name="backup-admonition"
tag="div"
class="flex flex-col gap-3"
>
<Admonition v-for="item in admonitions" :key="item.key" :type="getAdmonitionType(item.state)">
<template #icon="{ iconClass }">
<component :is="getIcon(item.state)" :class="iconClass" />
</template>
<template #header>
<div class="flex items-center gap-2">
<span>{{ getTitle(item) }}</span>
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
</div>
</div>
</template>
{{ getDescription(item) }}
<template #top-right-actions>
<ButtonStyled v-if="isQueued(item) || isInProgress(item)" type="outlined" color="blue">
<button class="!border" @click="handleCancel(item.backupId)">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="item.state === 'failed'" color="red">
<button @click="handleRetry(item.backupId, item.key)">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="item.state === 'failed' || item.state === 'done'"
circular
type="transparent"
hover-color-fill="background"
:color="getButtonColor(item.state)"
>
<button @click="handleDismiss(item.key)">
<XIcon />
</button>
</ButtonStyled>
</template>
<template v-if="isInProgress(item)" #progress>
<div class="pl-9">
<ProgressBar
:progress="item.progress"
color="blue"
:waiting="item.progress === 0"
full-width
/>
</div>
</template>
</Admonition>
</TransitionGroup>
</template>
<style scoped>
.backup-admonition-enter-active,
.backup-admonition-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.backup-admonition-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-move {
transition: transform 300ms ease-in-out;
}
</style>
@@ -1,6 +1,6 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<NewModal ref="modal" header="Renaming backup" width="500px" @show="focusInput">
<div class="flex flex-col gap-2">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
@@ -20,26 +20,28 @@
</span>
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -64,10 +66,10 @@ const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
@@ -80,7 +82,7 @@ const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const trimmedName = computed(() => backupName.value.trim())
@@ -110,7 +112,7 @@ const focusInput = () => {
})
}
function show(backup: Archon.Backups.v1.Backup) {
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
@@ -1,10 +1,10 @@
<template>
<NewModal ref="modal" header="Restore backup" fade="danger">
<div class="flex flex-col gap-6 max-w-[400px]">
<NewModal ref="modal" header="Restore backup" fade="danger" width="500px">
<div class="flex flex-col gap-6">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup.
</Admonition>
<Admonition v-else type="critical" header="Restore warning">
<Admonition v-else type="critical" header="Your server files will be replaced">
Restoring your server will replace the current world and server files. Any changes made
since that backup will be permanently lost.
</Admonition>
@@ -17,8 +17,8 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
Cancel
</button>
@@ -56,18 +56,24 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
function safetyBackupName(backupName: string) {
const base = `Before restoring "${backupName}"`
return base.slice(0, 92)
}
const restoreMutation = useMutation({
mutationFn: (backupId: string) =>
client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId),
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const isRestoring = ref(false)
function show(backup: Archon.Backups.v1.Backup) {
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup
modal.value?.show()
}
@@ -85,22 +91,24 @@ const restoreBackup = () => {
}
isRestoring.value = true
restoreMutation.mutate(currentBackup.value.id, {
onSuccess: () => {
// Optimistically update backupsState to show restore in progress immediately
ctx.backupsState.set(currentBackup.value!.id, {
restore: { progress: 0, state: 'ongoing' },
})
modal.value?.hide()
restoreMutation.mutate(
{
backupId: currentBackup.value.id,
name: safetyBackupName(currentBackup.value.name),
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
{
onSuccess: () => {
modal.value?.hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
},
onSettled: () => {
isRestoring.value = false
},
},
onSettled: () => {
isRestoring.value = false
},
})
)
}
defineExpose({
@@ -1,7 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue'
@@ -1,3 +1,4 @@
export * from './admonitions'
export * from './backups'
export * from './flows'
export * from './icons'