From 6e7835fb354754e464a9c61b2d2cfbd0f45c2b49 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Thu, 21 May 2026 17:49:48 +0100 Subject: [PATCH] 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 --- packages/api-client/src/modules/index.ts | 2 + .../src/modules/kyros/content/v1.ts | 1 + .../api-client/src/modules/kyros/files/v0.ts | 1 + .../api-client/src/modules/kyros/types.ts | 28 +++ .../src/modules/kyros/upload-sessions/v1.ts | 104 +++++++++ .../servers}/ServerManageStats.vue | 0 .../admonitions/ServerPanelAdmonitions.vue | 23 +- .../servers/admonitions/UploadAdmonition.vue | 22 +- .../hosting}/backups-selection.ts | 0 .../hosting/kyros-session-upload.ts | 212 ++++++++++++++++++ .../composables/server-manage-core-runtime.ts | 4 +- .../wrapped/hosting/manage/backups.vue | 3 +- .../wrapped/hosting/manage/content.vue | 46 +--- .../layouts/wrapped/hosting/manage/files.vue | 101 ++++----- .../wrapped/hosting/manage/overview.vue | 4 +- packages/ui/src/providers/server-context.ts | 4 +- .../stories/servers/EditServerIcon.stories.ts | 4 +- .../servers/ServerPanelAdmonitions.stories.ts | 5 +- 18 files changed, 455 insertions(+), 109 deletions(-) create mode 100644 packages/api-client/src/modules/kyros/upload-sessions/v1.ts rename packages/ui/src/{layouts/wrapped/hosting/manage/components => components/servers}/ServerManageStats.vue (100%) rename packages/ui/src/{layouts/wrapped/hosting/manage => composables/hosting}/backups-selection.ts (100%) create mode 100644 packages/ui/src/composables/hosting/kyros-session-upload.ts diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 7219d12e5..f1bb4f666 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -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, diff --git a/packages/api-client/src/modules/kyros/content/v1.ts b/packages/api-client/src/modules/kyros/content/v1.ts index 205c2ff69..9f5f4f8b1 100644 --- a/packages/api-client/src/modules/kyros/content/v1.ts +++ b/packages/api-client/src/modules/kyros/content/v1.ts @@ -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, diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index fb8f4d19b..693b89a81 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -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, diff --git a/packages/api-client/src/modules/kyros/types.ts b/packages/api-client/src/modules/kyros/types.ts index 102c72be7..b649ba645 100644 --- a/packages/api-client/src/modules/kyros/types.ts +++ b/packages/api-client/src/modules/kyros/types.ts @@ -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 { diff --git a/packages/api-client/src/modules/kyros/upload-sessions/v1.ts b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts new file mode 100644 index 000000000..d88d6dacc --- /dev/null +++ b/packages/api-client/src/modules/kyros/upload-sessions/v1.ts @@ -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 { + return this.client.request( + `/worlds/${worldId}/files/upload-session`, + { + api: '', + version: 'v1', + method: 'POST', + useNodeAuth: true, + }, + ) + } + + public async get( + scope: Kyros.UploadSessions.v1.Scope, + worldId: string, + ): Promise { + return this.client.request( + `/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 { + const formData = new FormData() + for (const { file, filename } of files) { + formData.append('file', file, filename) + } + + return this.client.upload( + `/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 { + return this.client.request( + `/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 { + return this.client.request( + `/worlds/${worldId}/files/upload-session/${uploadId}`, + { + api: '', + version: 'v1', + method: 'DELETE', + useNodeAuth: true, + }, + ) + } +} diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue b/packages/ui/src/components/servers/ServerManageStats.vue similarity index 100% rename from packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue rename to packages/ui/src/components/servers/ServerManageStats.vue diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue index 6d429417c..c157327f9 100644 --- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue +++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue @@ -92,6 +92,7 @@ const filesBusyHeader = computed(() => const dismissedIds = reactive(new Set()) const cancellingIds = reactive(new Set()) +const uploadCancelling = ref(false) const dismissedContentErrorKey = ref(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[] = [] for (const it of stackItems.value) { @@ -375,7 +391,12 @@ function onContentErrorDismiss() { @dismiss="onContentErrorDismiss" @retry="emit('content-retry')" /> - + -