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 19:03:48 +00:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions
@@ -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'