You've already forked AstralRinth
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
This commit is contained in:
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
|
||||
import { KyrosContentV1Module } from './kyros/content/v1'
|
||||
import { KyrosFilesV0Module } from './kyros/files/v0'
|
||||
import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
|
||||
kyros_content_v1: KyrosContentV1Module,
|
||||
kyros_files_v0: KyrosFilesV0Module,
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
|
||||
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
|
||||
* @param files - Files to upload as addons
|
||||
* @param options - Optional progress callback
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
|
||||
*/
|
||||
public uploadAddonFile(
|
||||
worldId: string,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
|
||||
* @param file - File to upload
|
||||
* @param options - Optional progress callback and feature overrides
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
|
||||
*/
|
||||
public uploadFile(
|
||||
path: string,
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export namespace Kyros {
|
||||
export namespace UploadSessions {
|
||||
export namespace v1 {
|
||||
export type Scope = 'content' | 'files'
|
||||
export type UploadSessionStatus =
|
||||
| 'active'
|
||||
| 'uploading'
|
||||
| 'finalizing'
|
||||
| 'cancelled'
|
||||
| 'finalized'
|
||||
| 'expired'
|
||||
|
||||
export interface UploadSessionResponse {
|
||||
upload_id: string
|
||||
status: UploadSessionStatus
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_upload_at: number | null
|
||||
expires_at: number
|
||||
entry_count: number
|
||||
uploaded_byte_count: number
|
||||
}
|
||||
|
||||
export interface GetUploadSessionResponse {
|
||||
session: UploadSessionResponse | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Files {
|
||||
export namespace v0 {
|
||||
export interface DirectoryItem {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
export type UploadSessionFile = {
|
||||
file: File | Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_upload_sessions_v1'
|
||||
}
|
||||
|
||||
public async create(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async get(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'GET',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public uploadFiles(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
files: UploadSessionFile[],
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
const formData = new FormData()
|
||||
for (const { file, filename } of files) {
|
||||
formData.append('file', file, filename)
|
||||
}
|
||||
|
||||
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
formData,
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async finalize(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async cancel(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'DELETE',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ const filesBusyHeader = computed(() =>
|
||||
|
||||
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(() =>
|
||||
@@ -327,6 +328,21 @@ async function onBackupRetry(item: BackupAdmonitionEntry) {
|
||||
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) {
|
||||
@@ -375,7 +391,12 @@ function onContentErrorDismiss() {
|
||||
@dismiss="onContentErrorDismiss"
|
||||
@retry="emit('content-retry')"
|
||||
/>
|
||||
<UploadAdmonition v-else-if="item.kind === 'upload'" />
|
||||
<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"
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
Math.round(overallProgress * 100)
|
||||
}}%)
|
||||
</span>
|
||||
<template v-if="cancelUpload" #top-right-actions>
|
||||
<template v-if="cancelable" #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
|
||||
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
@@ -32,12 +34,26 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
cancelable?: boolean
|
||||
cancelling?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelable: true,
|
||||
cancelling: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const state = computed(() => ctx.uploadState.value)
|
||||
const cancelUpload = computed(() => ctx.cancelUpload.value)
|
||||
|
||||
const overallProgress = computed(() => {
|
||||
const s = state.value
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type {
|
||||
AbstractModrinthClient,
|
||||
Kyros,
|
||||
UploadProgress,
|
||||
UploadState,
|
||||
} from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { CancelUploadHandler } from '#ui/providers/server-context'
|
||||
|
||||
export type UploadSessionUploadFile = {
|
||||
file: File
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type UploadSessionUploadResult = 'completed' | 'cancelled'
|
||||
|
||||
export function useUploadSessionUpload(options: {
|
||||
client: AbstractModrinthClient
|
||||
scope: Kyros.UploadSessions.v1.Scope
|
||||
worldId: Ref<string | null>
|
||||
uploadState: Ref<UploadState>
|
||||
cancelUpload: Ref<CancelUploadHandler | null>
|
||||
}) {
|
||||
let activeUploadCancel: CancelUploadHandler | null = null
|
||||
|
||||
function getUploadByteCount(files: File[]) {
|
||||
return files.reduce((sum, file) => sum + file.size, 0)
|
||||
}
|
||||
|
||||
function resetUploadState() {
|
||||
options.uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function startUploadState(files: File[]) {
|
||||
options.uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: files[0]?.name ?? null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: getUploadByteCount(files),
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
}
|
||||
|
||||
function setUploadProgressFromBytes(files: File[], uploadedBytes: number) {
|
||||
const totalBytes = getUploadByteCount(files)
|
||||
const boundedUploadedBytes = Math.max(0, Math.min(totalBytes, uploadedBytes))
|
||||
let previousBytes = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const nextBytes = previousBytes + file.size
|
||||
if (boundedUploadedBytes >= nextBytes) {
|
||||
previousBytes = nextBytes
|
||||
continue
|
||||
}
|
||||
|
||||
options.uploadState.value.currentFileName = file.name
|
||||
options.uploadState.value.currentFileProgress =
|
||||
file.size === 0 ? 1 : (boundedUploadedBytes - previousBytes) / file.size
|
||||
options.uploadState.value.uploadedBytes = boundedUploadedBytes
|
||||
options.uploadState.value.totalBytes = totalBytes
|
||||
options.uploadState.value.completedFiles = i
|
||||
return
|
||||
}
|
||||
|
||||
options.uploadState.value.currentFileName =
|
||||
files.length > 0 ? files[files.length - 1].name : null
|
||||
options.uploadState.value.currentFileProgress = files.length > 0 ? 1 : 0
|
||||
options.uploadState.value.uploadedBytes = totalBytes
|
||||
options.uploadState.value.totalBytes = totalBytes
|
||||
options.uploadState.value.completedFiles = files.length
|
||||
}
|
||||
|
||||
function setUploadProgressFromXhr(files: File[], progress: UploadProgress) {
|
||||
const totalBytes = getUploadByteCount(files)
|
||||
const uploadedBytes =
|
||||
progress.total > 0
|
||||
? Math.round(totalBytes * progress.progress)
|
||||
: Math.min(progress.loaded, totalBytes)
|
||||
setUploadProgressFromBytes(files, uploadedBytes)
|
||||
}
|
||||
|
||||
async function cancelUploadSession(worldId: string, uploadId: string) {
|
||||
try {
|
||||
await options.client.kyros.upload_sessions_v1.cancel(options.scope, worldId, uploadId)
|
||||
} catch {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelUpload() {
|
||||
await activeUploadCancel?.()
|
||||
}
|
||||
|
||||
async function uploadFiles(files: UploadSessionUploadFile[]): Promise<UploadSessionUploadResult> {
|
||||
if (files.length === 0) return 'cancelled'
|
||||
if (options.uploadState.value.isUploading) return 'cancelled'
|
||||
const worldId = options.worldId.value
|
||||
if (!worldId) return 'cancelled'
|
||||
|
||||
const sourceFiles = files.map(({ file }) => file)
|
||||
startUploadState(sourceFiles)
|
||||
|
||||
let cancelled = false
|
||||
let finalized = false
|
||||
let uploadId: string | null = null
|
||||
let uploadHandle: { cancel: () => void } | null = null
|
||||
let cancelRequest: Promise<void> | null = null
|
||||
let cancelCompletion: Promise<void> | null = null
|
||||
let resolveCancelCompletion: (() => void) | null = null
|
||||
const waitForCancelCompletion = () => {
|
||||
cancelCompletion ??= new Promise<void>((resolve) => {
|
||||
resolveCancelCompletion = resolve
|
||||
})
|
||||
return cancelCompletion
|
||||
}
|
||||
const completeCancel = () => {
|
||||
resolveCancelCompletion?.()
|
||||
resolveCancelCompletion = null
|
||||
cancelCompletion = null
|
||||
}
|
||||
const cancelSessionOnce = async () => {
|
||||
if (!uploadId) return
|
||||
cancelRequest ??= cancelUploadSession(worldId, uploadId)
|
||||
await cancelRequest
|
||||
}
|
||||
const finishCancellation = async () => {
|
||||
await cancelSessionOnce()
|
||||
completeCancel()
|
||||
}
|
||||
const cancelCurrentUpload = async () => {
|
||||
cancelled = true
|
||||
uploadHandle?.cancel()
|
||||
if (!uploadId) {
|
||||
await waitForCancelCompletion()
|
||||
return
|
||||
}
|
||||
await finishCancellation()
|
||||
}
|
||||
|
||||
activeUploadCancel = cancelCurrentUpload
|
||||
options.cancelUpload.value = cancelCurrentUpload
|
||||
|
||||
try {
|
||||
const session = await options.client.kyros.upload_sessions_v1.create(options.scope, worldId)
|
||||
uploadId = session.upload_id
|
||||
|
||||
if (cancelled) {
|
||||
await finishCancellation()
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
uploadHandle = options.client.kyros.upload_sessions_v1.uploadFiles(
|
||||
options.scope,
|
||||
worldId,
|
||||
uploadId,
|
||||
files,
|
||||
{
|
||||
onProgress: (progress) => setUploadProgressFromXhr(sourceFiles, progress),
|
||||
},
|
||||
)
|
||||
|
||||
await uploadHandle.promise
|
||||
if (cancelled) {
|
||||
await finishCancellation()
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
setUploadProgressFromBytes(sourceFiles, getUploadByteCount(sourceFiles))
|
||||
await options.client.kyros.upload_sessions_v1.finalize(options.scope, worldId, uploadId)
|
||||
finalized = true
|
||||
return 'completed'
|
||||
} catch (error) {
|
||||
if (uploadId && !finalized) {
|
||||
await finishCancellation()
|
||||
} else if (cancelled) {
|
||||
completeCancel()
|
||||
}
|
||||
if (cancelled || (error instanceof Error && error.message === 'Upload cancelled')) {
|
||||
return 'cancelled'
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (activeUploadCancel === cancelCurrentUpload) {
|
||||
activeUploadCancel = null
|
||||
}
|
||||
if (options.cancelUpload.value === cancelCurrentUpload) {
|
||||
options.cancelUpload.value = null
|
||||
}
|
||||
if (cancelled) {
|
||||
completeCancel()
|
||||
}
|
||||
resetUploadState()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cancelUpload,
|
||||
uploadFiles,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileOperation } from '../layouts/shared/files-tab/types'
|
||||
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
|
||||
import type { BusyReason } from '../providers/server-context'
|
||||
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
|
||||
import { defineMessage } from './i18n'
|
||||
import { useModrinthServersConsole } from './server-console'
|
||||
|
||||
@@ -355,7 +355,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
const cancelUpload = ref<(() => void) | null>(null)
|
||||
const cancelUpload = ref<CancelUploadHandler | null>(null)
|
||||
|
||||
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
||||
const dismissedOpIds = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -258,6 +258,7 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
|
||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
||||
@@ -268,8 +269,6 @@ import {
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { useBackupsSelection } from './backups-selection'
|
||||
|
||||
const messages = defineMessages({
|
||||
selectAll: {
|
||||
id: 'servers.backups.toolbar.select-all',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -113,6 +114,13 @@ const messages = defineMessages({
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
injectModrinthServerContext()
|
||||
const contentUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
scope: 'content',
|
||||
worldId,
|
||||
uploadState,
|
||||
cancelUpload,
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const route = useRoute()
|
||||
@@ -812,47 +820,17 @@ function handleUploadFiles() {
|
||||
const wid = worldId.value
|
||||
if (!wid) return
|
||||
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
|
||||
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
|
||||
onProgress: (p) => {
|
||||
uploadState.value.currentFileProgress = p.progress
|
||||
uploadState.value.uploadedBytes = p.loaded
|
||||
uploadState.value.totalBytes = p.total
|
||||
},
|
||||
})
|
||||
cancelUpload.value = () => handle.cancel()
|
||||
|
||||
try {
|
||||
await handle.promise
|
||||
uploadState.value.completedFiles = files.length
|
||||
await contentQuery.refetch()
|
||||
const result = await contentUploadSession.uploadFiles(
|
||||
files.map((file) => ({ file, filename: file.name })),
|
||||
)
|
||||
if (result === 'completed') await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'Upload cancelled') return
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToUpload),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
cancelUpload.value = null
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -25,7 +26,21 @@ const props = defineProps<{
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
|
||||
const {
|
||||
serverId,
|
||||
worldId,
|
||||
fsOps,
|
||||
busyReasons,
|
||||
uploadState,
|
||||
cancelUpload: cancelUploadRef,
|
||||
} = serverContext
|
||||
const fileUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
scope: 'files',
|
||||
worldId,
|
||||
uploadState,
|
||||
cancelUpload: cancelUploadRef,
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -113,7 +128,13 @@ const {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
||||
function isVisibleFileItem(item: Kyros.Files.v0.DirectoryItem) {
|
||||
return !item.path.split('/').includes('.modrinth-staged')
|
||||
}
|
||||
|
||||
const items = computed<FileItem[]>(() =>
|
||||
(directoryData.value?.items ?? []).filter(isVisibleFileItem),
|
||||
)
|
||||
|
||||
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
|
||||
|
||||
@@ -365,71 +386,33 @@ async function restartServer() {
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
}
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
function getSessionUploadFilename(fileName: string) {
|
||||
const basePath = currentPath.value.split('/').filter(Boolean).join('/')
|
||||
return basePath ? `${basePath}/${fileName}` : fileName
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
|
||||
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: files[0].name,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes,
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
cancelUploadRef.value = () => activeUploadCancel?.()
|
||||
|
||||
let completedBytes = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace('//', '/')
|
||||
|
||||
uploadState.value.currentFileName = file.name
|
||||
uploadState.value.currentFileProgress = 0
|
||||
|
||||
try {
|
||||
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
uploadState.value.currentFileProgress = progress
|
||||
uploadState.value.uploadedBytes = completedBytes + Math.round(file.size * progress)
|
||||
},
|
||||
})
|
||||
activeUploadCancel = () => uploader.cancel()
|
||||
|
||||
await uploader.promise
|
||||
completedBytes += file.size
|
||||
uploadState.value.completedFiles = i + 1
|
||||
uploadState.value.uploadedBytes = completedBytes
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'Upload cancelled') break
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
activeUploadCancel = null
|
||||
cancelUploadRef.value = null
|
||||
refreshList()
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
try {
|
||||
const result = await fileUploadSession.uploadFiles(
|
||||
files.map((file) => ({
|
||||
file,
|
||||
filename: getSessionUploadFilename(file.name),
|
||||
})),
|
||||
)
|
||||
if (result === 'completed') refreshList()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
activeUploadCancel?.()
|
||||
fileUploadSession.cancelUpload()
|
||||
}
|
||||
|
||||
// Provide the file manager context
|
||||
|
||||
@@ -35,17 +35,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
|
||||
import { useModrinthServersConsole } from '#ui/composables'
|
||||
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import ServerManageStats from './components/ServerManageStats.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showAdvancedDebugInfo?: boolean
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface FilesystemAuth {
|
||||
token: string
|
||||
}
|
||||
|
||||
export type CancelUploadHandler = () => void | Promise<void>
|
||||
|
||||
export interface ModrinthServerContext {
|
||||
readonly serverId: string
|
||||
readonly worldId: Ref<string | null>
|
||||
@@ -44,7 +46,7 @@ export interface ModrinthServerContext {
|
||||
|
||||
// File upload state
|
||||
readonly uploadState: Ref<UploadState>
|
||||
readonly cancelUpload: Ref<(() => void) | null>
|
||||
readonly cancelUpload: Ref<CancelUploadHandler | null>
|
||||
|
||||
// File operations (extract, move, etc.)
|
||||
readonly activeOperations: ComputedRef<FileOperation[]>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue'
|
||||
import { provideModrinthServerContext } from '../../providers'
|
||||
import type { ModrinthServerContext } from '../../providers/server-context'
|
||||
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
|
||||
|
||||
const meta = {
|
||||
title: 'Servers/EditServerIcon',
|
||||
@@ -73,7 +73,7 @@ const meta = {
|
||||
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
|
||||
refreshFsAuth: async () => {},
|
||||
uploadState,
|
||||
cancelUpload: ref(null),
|
||||
cancelUpload: ref<CancelUploadHandler | null>(null),
|
||||
activeOperations: computed(() => []),
|
||||
dismissOperation: async () => {},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import ServerPanelAdmonitions from '../../components/servers/admonitions/ServerP
|
||||
import { defineMessage } from '../../composables/i18n'
|
||||
import type { FileOperation } from '../../layouts/shared/files-tab/types'
|
||||
import { provideModrinthServerContext } from '../../providers'
|
||||
import type { ModrinthServerContext } from '../../providers/server-context'
|
||||
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
|
||||
|
||||
const meta = {
|
||||
title: 'Servers/ServerPanelAdmonitions',
|
||||
@@ -92,7 +92,8 @@ const meta = {
|
||||
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
|
||||
refreshFsAuth: async () => {},
|
||||
uploadState,
|
||||
cancelUpload: ref(() => {
|
||||
cancelUpload: ref<CancelUploadHandler | null>(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
uploadState.value = { ...uploadState.value, isUploading: false }
|
||||
}),
|
||||
activeOperations: computed(() => fileOp.value),
|
||||
|
||||
Reference in New Issue
Block a user