mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 13:05:50 +00:00
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:
@@ -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'
|
||||
Reference in New Issue
Block a user