Files
modrinth/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
T
Calum H. 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00

432 lines
12 KiB
Vue

<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 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
}>()
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 ||
ctx.busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
),
)
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 uploadCancelling = ref(false)
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 = bannerCoversInstalling.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 onUploadCancel() {
if (uploadCancelling.value) return
const cancel = ctx.cancelUpload.value
if (!cancel) return
uploadCancelling.value = true
try {
await cancel()
} catch (err) {
console.error('Failed to cancel upload', err)
} finally {
uploadCancelling.value = false
}
}
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"
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
<UploadAdmonition
v-else-if="item.kind === 'upload'"
:cancelable="!!ctx.cancelUpload.value"
:cancelling="uploadCancelling"
@cancel="onUploadCancel"
/>
<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>