feat: backups alignment with Figma (#5559)

* feat: backup admonitions

* feat: align modals + fix backupitem

* fix: body needs opac 80

* fix: lint
This commit is contained in:
Calum H.
2026-03-13 22:27:06 +00:00
committed by GitHub
parent 31b541007d
commit 8a2125ef16
16 changed files with 715 additions and 157 deletions

View File

@@ -32,7 +32,7 @@
:options="languageOptions" :options="languageOptions"
searchable searchable
include-select-all-option include-select-all-option
:maxTagRows="2" :max-tag-rows="2"
placeholder="Select languages" placeholder="Select languages"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />

View File

@@ -311,6 +311,7 @@
</template> </template>
</InstallingBanner> </InstallingBanner>
</Transition> </Transition>
<BackupProgressAdmonitions class="mb-4" />
<NuxtPage <NuxtPage
:route="route" :route="route"
:is-connected="isConnected" :is-connected="isConnected"
@@ -357,6 +358,7 @@ import {
} from '@modrinth/assets' } from '@modrinth/assets'
import type { BusyReason } from '@modrinth/ui' import type { BusyReason } from '@modrinth/ui'
import { import {
BackupProgressAdmonitions,
ButtonStyled, ButtonStyled,
defineMessage, defineMessage,
ErrorInformationCard, ErrorInformationCard,

View File

@@ -52,7 +52,7 @@ import ButtonStyled from './ButtonStyled.vue'
withDefaults( withDefaults(
defineProps<{ defineProps<{
type?: 'info' | 'warning' | 'critical' type?: 'info' | 'warning' | 'critical' | 'success'
header?: string header?: string
body?: string body?: string
showActionsUnderneath?: boolean showActionsUnderneath?: boolean
@@ -75,17 +75,20 @@ const typeClasses = {
info: 'border-brand-blue bg-bg-blue', info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange', warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red', critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
} }
const iconClasses = { const iconClasses = {
info: 'text-brand-blue', info: 'text-brand-blue',
warning: 'text-brand-orange', warning: 'text-brand-orange',
critical: 'text-brand-red', critical: 'text-brand-red',
success: 'text-brand-green',
} }
const buttonColors: Record<string, 'blue' | 'orange' | 'red'> = { const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
info: 'blue', info: 'blue',
warning: 'orange', warning: 'orange',
critical: 'red', critical: 'red',
success: 'green',
} }
</script> </script>

View File

@@ -1,17 +1,13 @@
<template> <template>
<NewModal ref="modal" header="Delete backup" fade="danger"> <NewModal ref="modal" header="Delete backup" fade="danger">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6 max-w-[400px]">
<Admonition type="critical" header="Delete warning"> <Admonition type="critical" header="Delete warning">
This backup will be permanently deleted. This action cannot be undone. This backup will be permanently deleted. This action cannot be undone.
</Admonition> </Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2"> <div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span> <span class="font-semibold text-contrast">Backup</span>
<BackupItem <BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
:backup="currentBackup"
preview
class="!bg-surface-2 border-solid border-[1px] border-surface-5"
/>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,6 @@ import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils' import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue' import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue' import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({ const formatDateTime = useFormatDateTime({
@@ -48,12 +47,6 @@ const props = withDefaults(
}, },
) )
const backupQueued = computed(
() =>
props.backup.status === 'pending' ||
props.backup.task?.create?.progress === 0 ||
(props.backup.status === 'in_progress' && !props.backup.task?.create),
)
const failedToCreate = computed( const failedToCreate = computed(
() => props.backup.status === 'error' || props.backup.status === 'timed_out', () => props.backup.status === 'error' || props.backup.status === 'timed_out',
) )
@@ -63,30 +56,30 @@ const inactiveStates = ['failed', 'cancelled', 'done']
const creating = computed(() => { const creating = computed(() => {
const task = props.backup.task?.create const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) { if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task return true
} }
if ( if (
(props.backup.status === 'in_progress' || props.backup.status === 'pending') && (props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
!props.backup.task?.restore !props.backup.task?.restore
) { ) {
return { progress: 0, state: 'ongoing' as const } return true
} }
return undefined return false
}) })
const restoring = computed(() => { const restoring = computed(() => {
const task = props.backup.task?.restore const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) { if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task return true
} }
return undefined return false
}) })
const restoreQueued = computed(() => restoring.value?.progress === 0)
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed') const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const activeOperation = computed(() => creating.value || restoring.value)
const backupIcon = computed(() => { const backupIcon = computed(() => {
if (props.backup.automated) { if (props.backup.automated) {
return ClockIcon return ClockIcon
@@ -97,8 +90,7 @@ const backupIcon = computed(() => {
const overflowMenuOptions = computed<OverflowOption[]>(() => { const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = [] const options: OverflowOption[] = []
// Download only available when not creating if (!activeOperation.value) {
if (!creating.value) {
options.push({ options.push({
id: 'download', id: 'download',
action: () => emit('download'), action: () => emit('download'),
@@ -109,8 +101,7 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
options.push({ id: 'rename', action: () => emit('rename') }) options.push({ id: 'rename', action: () => emit('rename') })
// Delete only available when not creating (has separate Cancel button) if (!activeOperation.value) {
if (!creating.value) {
options.push({ divider: true }) options.push({ divider: true })
options.push({ options.push({
id: 'delete', id: 'delete',
@@ -138,22 +129,6 @@ const messages = defineMessages({
id: 'servers.backups.item.rename', id: 'servers.backups.item.rename',
defaultMessage: 'Rename', defaultMessage: 'Rename',
}, },
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Backup queued',
},
queuedForRestore: {
id: 'servers.backups.item.queued-for-restore',
defaultMessage: 'Restore queued',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: { failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup', id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup', defaultMessage: 'Failed to create backup',
@@ -179,7 +154,7 @@ const messages = defineMessages({
<template> <template>
<div <div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md" class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-2' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'" :class="preview ? 'grid-cols-1' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
> >
<div class="flex flex-row gap-4 items-center"> <div class="flex flex-row gap-4 items-center">
<div <div
@@ -199,7 +174,10 @@ const messages = defineMessages({
</span> </span>
</div> </div>
<div class="flex items-center gap-1.5 text-sm text-secondary"> <div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="failedToCreate || failedToRestore"> <template v-if="preview">
<span>{{ formatDateTime(backup.created_at) }}</span>
</template>
<template v-else-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" /> <XIcon class="size-4 text-red" />
<span class="text-red"> <span class="text-red">
{{ {{
@@ -228,95 +206,42 @@ const messages = defineMessages({
</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" class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
> >
<template v-if="creating || restoring"> <span class="w-full font-medium text-contrast md:text-center">
<ProgressBar {{ formatDateTime(backup.created_at) }}
:progress="(creating || restoring)!.progress" </span>
:color="creating ? 'brand' : 'purple'" <!-- TODO: Uncomment when API supports size field -->
:waiting="(creating || restoring)!.progress === 0" <!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
:label="
formatMessage(
creating
? backupQueued
? messages.queuedForBackup
: messages.creatingBackup
: restoreQueued
? messages.queuedForRestore
: messages.restoringBackup,
)
"
:label-class="creating ? 'text-contrast' : 'text-purple'"
show-progress
full-width
/>
</template>
<template v-else>
<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> -->
</template>
</div> </div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end"> <div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
<template v-if="failedToCreate"> <ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
<ButtonStyled> <button
<button @click="() => emit('retry')"> v-tooltip="props.restoreDisabled"
<RotateCounterClockwiseIcon class="size-5" /> class="!border-[1px]"
{{ formatMessage(commonMessages.retryButton) }} :disabled="!!props.restoreDisabled"
</button> @click="() => emit('restore')"
</ButtonStyled> >
<ButtonStyled> <RotateCounterClockwiseIcon class="size-5" />
<button @click="() => emit('delete', true)"> {{ formatMessage(messages.restore) }}
<TrashIcon class="size-5" /> </button>
{{ formatMessage(commonMessages.deleteLabel) }} </ButtonStyled>
</button> <ButtonStyled circular type="transparent">
</ButtonStyled> <OverflowMenu :options="overflowMenuOptions">
</template> <MoreVerticalIcon class="size-5" />
<template v-else-if="creating"> <template #download>
<ButtonStyled type="outlined"> <DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
<button class="!border-[1px] !border-surface-5" @click="() => emit('delete')"> </template>
{{ formatMessage(commonMessages.cancelButton) }} <template #rename>
</button> <EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</ButtonStyled> </template>
<ButtonStyled circular type="transparent"> <template #delete>
<OverflowMenu :options="overflowMenuOptions"> <TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
<MoreVerticalIcon class="size-5" /> </template>
<template #rename> </OverflowMenu>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }} </ButtonStyled>
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border-[1px]"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div> </div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{ <pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const props = withDefaults(
defineProps<{
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
backupName?: string
createdAt?: string
}>(),
{
backupName: undefined,
createdAt: undefined,
},
)
const emit = defineEmits<{
(e: 'cancel' | 'retry' | 'dismiss'): void
}>()
const isQueued = computed(() => props.state === 'ongoing' && props.progress === 0)
const isInProgress = computed(() => props.state === 'ongoing' && props.progress > 0)
const isFailed = computed(() => props.state === 'failed')
const isSuccess = computed(() => props.state === 'done')
const showCancel = computed(() => isQueued.value || isInProgress.value)
const showRetry = computed(() => isFailed.value)
const showDismiss = computed(() => isFailed.value || isSuccess.value)
const showProgress = computed(() => isInProgress.value)
const colorClasses = computed(() => {
if (isFailed.value) return 'border-brand-red bg-bg-red'
if (isSuccess.value) return 'border-brand-green bg-bg-green'
return 'border-brand-blue bg-bg-blue'
})
const icon = computed(() => {
if (isFailed.value) return TriangleAlertIcon
if (isSuccess.value) return CheckCircleIcon
return InfoIcon
})
const iconClass = computed(() => {
if (isFailed.value) return 'text-brand-red'
if (isSuccess.value) return 'text-brand-green'
return 'text-brand-blue'
})
const buttonColor = computed<'red' | 'green' | 'blue'>(() => {
if (isFailed.value) return 'red'
if (isSuccess.value) return 'green'
return 'blue'
})
const name = computed(() => props.backupName ?? formatMessage(messages.fallbackName))
const title = computed(() => {
if (props.type === 'create') {
if (isQueued.value) return formatMessage(messages.backupQueuedTitle)
if (isInProgress.value) return formatMessage(messages.creatingBackupTitle)
if (isFailed.value) return formatMessage(messages.backupFailedTitle)
}
if (isQueued.value) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress.value) return formatMessage(messages.restoringBackupTitle)
if (isSuccess.value) return formatMessage(messages.restoreSuccessfulTitle)
if (isFailed.value) return formatMessage(messages.restoreFailedTitle)
return ''
})
const description = computed(() => {
if (props.type === 'create') {
if (isQueued.value)
return formatMessage(messages.backupQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.creatingBackupDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.backupFailedDescription, { backupName: name.value })
}
if (isQueued.value)
return formatMessage(messages.restoreQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.restoringBackupDescription, { backupName: name.value })
if (isSuccess.value)
return formatMessage(messages.restoreSuccessfulDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.restoreFailedDescription, { backupName: name.value })
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>
<div :class="['flex flex-col rounded-2xl border border-solid p-4', colorClasses]">
<div class="flex items-start gap-2">
<div class="flex flex-1 gap-3 items-start">
<component :is="icon" :class="['size-6 shrink-0', iconClass]" />
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast">{{ title }}</span>
<div v-if="createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(createdAt) }}</span>
</div>
</div>
<span class="text-contrast opacity-80">{{ description }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="showCancel" type="outlined" color="blue">
<button class="!border" @click="emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showRetry" color="red">
<button @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="showDismiss"
circular
type="transparent"
hover-color-fill="background"
:color="buttonColor"
>
<button @click="emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showProgress" class="mt-4 pl-9">
<ProgressBar :progress="progress" color="blue" :waiting="progress === 0" full-width />
</div>
</div>
</template>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context'
import BackupProgressAdmonition from './BackupProgressAdmonition.vue'
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>()
// 1. Active WS entries (real-time progress from backupsState)
for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id)
if (entry.create && entry.create.state === 'ongoing') {
const key = `${id}:create`
if (!dismissedIds.has(key)) {
seenIds.add(id)
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)) {
seenIds.add(id)
result.push({
key,
backupId: id,
type: 'restore',
state: entry.restore.state,
progress: entry.restore.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
}
// 2. REST-based entries for pending/in_progress backups without WS data yet
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,
})
}
}
}
}
// 3. Terminal entries (snapshotted before cleanup)
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)
}
</script>
<template>
<TransitionGroup
v-if="admonitions.length > 0"
name="backup-admonition"
tag="div"
class="flex flex-col gap-3"
>
<BackupProgressAdmonition
v-for="item in admonitions"
:key="item.key"
:type="item.type"
:state="item.state"
:progress="item.progress"
:backup-name="item.name"
:created-at="item.createdAt"
@cancel="handleCancel(item.backupId)"
@retry="handleRetry(item.backupId, item.key)"
@dismiss="handleDismiss(item.key)"
/>
</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>

View File

@@ -1,17 +1,17 @@
<template> <template>
<NewModal ref="modal" header="Restore backup" fade="danger"> <NewModal ref="modal" header="Restore backup" fade="danger">
<div class="flex flex-col gap-6 max-w-[600px]"> <div class="flex flex-col gap-6 max-w-[400px]">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running"> <Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup. Stop the server before restoring a backup.
</Admonition> </Admonition>
<!-- TODO: Worlds: Replace "server" with "world" --> <Admonition v-else type="critical" header="Restore warning">
<Admonition v-else type="warning" header="Restore warning"> Restoring your server will replace the current world and server files. Any changes made
This will overwrite all files in the server and replace them with the files from the backup. since that backup will be permanently lost.
</Admonition> </Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2"> <div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span> <span class="font-semibold text-contrast">Backup</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2" /> <BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue' export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue' export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue' export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonition } from './BackupProgressAdmonition.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue' export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue' export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue' export { default as BackupWarning } from './BackupWarning.vue'

View File

@@ -144,6 +144,10 @@ const messages = defineMessages({
id: 'content.page-layout.busy-description', id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.', defaultMessage: 'Please wait for the operation to complete before editing content.',
}, },
pleaseWait: {
id: 'content.page-layout.please-wait',
defaultMessage: 'Please wait',
},
}) })
const ctx = injectContentManager() const ctx = injectContentManager()
@@ -493,7 +497,11 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:categories="ctx.modpack.value.categories" :categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate" :has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value" :disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="ctx.modpack.value.disabledText" :disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:show-content-hint=" :show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0) !!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
" "

View File

@@ -789,14 +789,21 @@ provideContentManager({
isBusy: computed(() => busyReasons.value.length > 0), isBusy: computed(() => busyReasons.value.length > 0),
busyMessage: computed(() => { busyMessage: computed(() => {
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
const nonBannerReasons = bannerCoversInstalling const filteredReasons = busyReasons.value.filter((r) => {
? busyReasons.value.filter( if (
(r) => bannerCoversInstalling &&
r.reason.id !== 'servers.busy.installing' && (r.reason.id === 'servers.busy.installing' ||
r.reason.id !== 'servers.busy.syncing-content', r.reason.id === 'servers.busy.syncing-content')
) )
: busyReasons.value return false
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null if (
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring'
)
return false
return true
})
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
}), }),
getItemId: (item) => item.file_name, getItemId: (item) => item.file_name,
contentTypeLabel: type, contentTypeLabel: type,

View File

@@ -21,8 +21,8 @@
</div> </div>
<div v-else key="content" class="contents"> <div v-else key="content" class="contents">
<Admonition v-if="serverBusy" type="warning" class="mb-5"> <Admonition v-if="nonBackupBusyReasons.length > 0" type="warning" class="mb-5">
<template #header>{{ busyTooltip }}</template> <template #header>{{ formatMessage(nonBackupBusyReasons[0].reason) }}</template>
File operations are disabled while the operation is in progress. File operations are disabled while the operation is in progress.
</Admonition> </Admonition>
<div class="relative flex w-full flex-col"> <div class="relative flex w-full flex-col">
@@ -327,6 +327,13 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
const busyTooltip = computed(() => const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined, busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
) )
const nonBackupBusyReasons = computed(() =>
busyReasons.value.filter(
(r) =>
r.reason.id !== 'servers.busy.backup-creating' &&
r.reason.id !== 'servers.busy.backup-restoring',
),
)
const queryClient = useQueryClient() const queryClient = useQueryClient()
interface BaseOperation { interface BaseOperation {

View File

@@ -335,6 +335,9 @@
"content.page-layout.no-content-found": { "content.page-layout.no-content-found": {
"defaultMessage": "No content found." "defaultMessage": "No content found."
}, },
"content.page-layout.please-wait": {
"defaultMessage": "Please wait"
},
"content.page-layout.search-placeholder": { "content.page-layout.search-placeholder": {
"defaultMessage": "Search {count} {contentType}..." "defaultMessage": "Search {count} {contentType}..."
}, },
@@ -1814,15 +1817,57 @@
"search.filter_type.shader_loader": { "search.filter_type.shader_loader": {
"defaultMessage": "Loader" "defaultMessage": "Loader"
}, },
"servers.backups.admonition.backup-failed.description": {
"defaultMessage": "Something went wrong while creating {backupName}. Please try again or contact support if the issue continues."
},
"servers.backups.admonition.backup-failed.title": {
"defaultMessage": "Backup failed"
},
"servers.backups.admonition.backup-queued.description": {
"defaultMessage": "{backupName} is queued and will start shortly."
},
"servers.backups.admonition.backup-queued.title": {
"defaultMessage": "Backup queued"
},
"servers.backups.admonition.creating-backup.description": {
"defaultMessage": "Saving world data and server configuration for {backupName}. This can take a few minutes."
},
"servers.backups.admonition.creating-backup.title": {
"defaultMessage": "Creating backup"
},
"servers.backups.admonition.fallback-name": {
"defaultMessage": "your backup"
},
"servers.backups.admonition.restore-failed.description": {
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."
},
"servers.backups.admonition.restore-failed.title": {
"defaultMessage": "Restoring from backup failed"
},
"servers.backups.admonition.restore-queued.description": {
"defaultMessage": "Restoring from {backupName} is queued and will start shortly."
},
"servers.backups.admonition.restore-queued.title": {
"defaultMessage": "Restoring from backup queued"
},
"servers.backups.admonition.restore-successful.description": {
"defaultMessage": "Your server has been restored to {backupName} and is ready to start."
},
"servers.backups.admonition.restore-successful.title": {
"defaultMessage": "Restoring from backup successful"
},
"servers.backups.admonition.restoring-backup.description": {
"defaultMessage": "Restoring your server from {backupName}. This may take a couple of minutes."
},
"servers.backups.admonition.restoring-backup.title": {
"defaultMessage": "Restoring from backup"
},
"servers.backups.item.auto": { "servers.backups.item.auto": {
"defaultMessage": "Auto" "defaultMessage": "Auto"
}, },
"servers.backups.item.backup-schedule": { "servers.backups.item.backup-schedule": {
"defaultMessage": "Backup schedule" "defaultMessage": "Backup schedule"
}, },
"servers.backups.item.creating-backup": {
"defaultMessage": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": { "servers.backups.item.failed-to-create-backup": {
"defaultMessage": "Failed to create backup" "defaultMessage": "Failed to create backup"
}, },
@@ -1832,21 +1877,12 @@
"servers.backups.item.manual-backup": { "servers.backups.item.manual-backup": {
"defaultMessage": "Manual backup" "defaultMessage": "Manual backup"
}, },
"servers.backups.item.queued-for-backup": {
"defaultMessage": "Backup queued"
},
"servers.backups.item.queued-for-restore": {
"defaultMessage": "Restore queued"
},
"servers.backups.item.rename": { "servers.backups.item.rename": {
"defaultMessage": "Rename" "defaultMessage": "Rename"
}, },
"servers.backups.item.restore": { "servers.backups.item.restore": {
"defaultMessage": "Restore" "defaultMessage": "Restore"
}, },
"servers.backups.item.restoring-backup": {
"defaultMessage": "Restoring from backup..."
},
"servers.notice.dismiss": { "servers.notice.dismiss": {
"defaultMessage": "Dismiss" "defaultMessage": "Dismiss"
}, },

View File

@@ -24,6 +24,7 @@ export const AllTypes: Story = {
<Admonition type="info" header="Info" body="This is an informational message." /> <Admonition type="info" header="Info" body="This is an informational message." />
<Admonition type="warning" header="Warning" body="This is a warning message." /> <Admonition type="warning" header="Warning" body="This is a warning message." />
<Admonition type="critical" header="Critical" body="This is a critical message." /> <Admonition type="critical" header="Critical" body="This is a critical message." />
<Admonition type="success" header="Success" body="This operation completed successfully." />
</div> </div>
`, `,
}), }),
@@ -36,3 +37,11 @@ export const WithHeader: Story = {
body: 'Please read this carefully before proceeding.', body: 'Please read this carefully before proceeding.',
}, },
} }
export const Success: Story = {
args: {
type: 'success',
header: 'Operation Complete',
body: 'Everything went smoothly.',
},
}

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BackupProgressAdmonition from '../../components/servers/backups/BackupProgressAdmonition.vue'
const meta = {
title: 'Servers/BackupProgressAdmonition',
component: BackupProgressAdmonition,
parameters: {
layout: 'padded',
},
} satisfies Meta<typeof BackupProgressAdmonition>
export default meta
type Story = StoryObj<typeof meta>
const justNow = new Date().toISOString()
const eightMinsAgo = new Date(Date.now() - 8 * 60 * 1000).toISOString()
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
export const AllStates: Story = {
render: () => ({
components: { BackupProgressAdmonition },
setup() {
const now = new Date().toISOString()
const mins8 = new Date(Date.now() - 8 * 60 * 1000).toISOString()
const hours5 = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
return { now, mins8, hours5 }
},
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 1020px;">
<h3 style="margin: 0; color: var(--color-contrast);">Backup Creation</h3>
<BackupProgressAdmonition type="create" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
<BackupProgressAdmonition type="create" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
<BackupProgressAdmonition type="create" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
<h3 style="margin: 1rem 0 0; color: var(--color-contrast);">Backup Restoration</h3>
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
<BackupProgressAdmonition type="restore" state="done" :progress="1" backup-name="World Backup 1" :created-at="hours5" />
<BackupProgressAdmonition type="restore" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
</div>
`,
}),
}
export const BackupQueued: Story = {
args: {
type: 'create',
state: 'ongoing',
progress: 0,
backupName: 'World Backup 1',
createdAt: justNow,
},
}
export const CreatingBackup: Story = {
args: {
type: 'create',
state: 'ongoing',
progress: 0.33,
backupName: 'World Backup 1',
createdAt: eightMinsAgo,
},
}
export const BackupFailed: Story = {
args: {
type: 'create',
state: 'failed',
progress: 0,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}
export const RestoreQueued: Story = {
args: {
type: 'restore',
state: 'ongoing',
progress: 0,
backupName: 'World Backup 1',
createdAt: justNow,
},
}
export const RestoringBackup: Story = {
args: {
type: 'restore',
state: 'ongoing',
progress: 0.33,
backupName: 'World Backup 1',
createdAt: eightMinsAgo,
},
}
export const RestoreSuccessful: Story = {
args: {
type: 'restore',
state: 'done',
progress: 1,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}
export const RestoreFailed: Story = {
args: {
type: 'restore',
state: 'failed',
progress: 0,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}

View File

@@ -4,6 +4,7 @@ import {
BracesIcon, BracesIcon,
CalendarIcon, CalendarIcon,
CardIcon, CardIcon,
CheckCircleIcon,
CurrencyIcon, CurrencyIcon,
DiscordIcon, DiscordIcon,
FileArchiveIcon, FileArchiveIcon,
@@ -66,6 +67,7 @@ export const SEVERITY_ICONS: Record<string, Component> = {
warning: IssuesIcon, warning: IssuesIcon,
error: XCircleIcon, error: XCircleIcon,
critical: XCircleIcon, critical: XCircleIcon,
success: CheckCircleIcon,
} }
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = { export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {