diff --git a/agent/app/service/image.go b/agent/app/service/image.go index b8f7e6587..768e9f99f 100644 --- a/agent/app/service/image.go +++ b/agent/app/service/image.go @@ -5,7 +5,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "os" @@ -28,6 +27,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/pkg/jsonmessage" "github.com/moby/go-archive" ) @@ -325,18 +325,16 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error { } go func() { - client, err := docker.NewDockerClient() - if err != nil { - taskItem.Log("Failed to create Docker client: " + err.Error()) - return - } - defer client.Close() - for _, itemPath := range req.Paths { currentPath := itemPath itemName := path.Base(currentPath) taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error { taskItem.Logf("----------------- %s -----------------", itemName) + client, err := docker.NewDockerClient() + if err != nil { + return err + } + defer client.Close() file, err := os.Open(currentPath) if err != nil { return err @@ -347,14 +345,7 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error { return err } defer res.Body.Close() - content, err := io.ReadAll(res.Body) - if err != nil { - return err - } - if strings.Contains(string(content), "Error") { - return errors.New(string(content)) - } - return nil + return consumeImageLoadResponse(res.Body, taskItem.Log) }, nil) } _ = taskItem.Execute() @@ -362,6 +353,29 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error { return nil } +// Docker may report load failures in a successful HTTP response's JSON stream. +func consumeImageLoadResponse(reader io.Reader, log func(string)) error { + decoder := json.NewDecoder(reader) + for { + var message jsonmessage.JSONMessage + if err := decoder.Decode(&message); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + if message.Error != nil && message.Error.Message != "" { + return message.Error + } + if message.ErrorMessage != "" { + return errors.New(message.ErrorMessage) + } + if text := strings.TrimSpace(message.Stream); text != "" { + log(text) + } + } +} + func (u *ImageService) ImageSave(req dto.ImageSave) error { taskItem, err := task.NewTaskWithOps(req.Name, task.TaskExport, task.TaskScopeImage, req.TaskID, 1) if err != nil { diff --git a/frontend/src/api/modules/container.ts b/frontend/src/api/modules/container.ts index e5176cfa5..731b5f4bf 100644 --- a/frontend/src/api/modules/container.ts +++ b/frontend/src/api/modules/container.ts @@ -144,8 +144,13 @@ export const imagePull = (params: Container.ImagePull) => { export const imagePush = (params: Container.ImagePush) => { return http.post(`/containers/image/push`, params); }; -export const imageLoad = (params: Container.ImageLoad) => { - return http.post(`/containers/image/load`, params, TimeoutEnum.T_10M); +export const imageLoad = (params: Container.ImageLoad, currentNode?: string) => { + return http.post( + `/containers/image/load`, + params, + TimeoutEnum.T_10M, + currentNode ? { CurrentNode: currentNode } : undefined, + ); }; export const imageSave = (params: Container.ImageSave) => { return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M); diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 7150fdb77..5ff9b1b96 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1878,6 +1878,11 @@ const message = { imageName: 'Image name', pull: 'Pull', path: 'Path', + imageUploadDrop: 'Drop an image file here, or click to upload', + imageUploadHelper: + 'Any file format is allowed. Docker validates the contents. Uploaded files are retained for retrying imports or manual deletion.', + imageUploadEmpty: 'The file is empty. Select a valid image archive.', + imageImportSubmitted: 'Image import task submitted. Check the task log for the result.', importImage: 'Import', buildArgs: 'Build Arguments', imageBuild: 'Build', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index ab56115b8..76f70aaab 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1902,6 +1902,12 @@ const message = { imageName: 'Nombre de la imagen', pull: 'Descargar', path: 'Ruta', + imageUploadDrop: 'Arrastre un archivo de imagen aquí o haga clic para subirlo', + imageUploadHelper: + 'Se permite cualquier formato de archivo. Docker valida el contenido. Los archivos subidos se conservan para reintentar la importación o eliminarlos manualmente.', + imageUploadEmpty: 'El archivo está vacío. Seleccione un archivo de imagen válido.', + imageImportSubmitted: + 'Tarea de importación de imagen enviada. Consulte el registro de la tarea para ver el resultado.', importImage: 'Importar', buildArgs: 'Argumentos de Construcción', imageBuild: 'Construir', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 21b6b02c0..88250e494 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -1862,6 +1862,11 @@ const message = { imageName: 'نام تصویر', pull: 'دریافت', path: 'مسیر', + imageUploadDrop: 'فایل ایمیج را اینجا بکشید یا برای بارگذاری کلیک کنید', + imageUploadHelper: + 'همه قالب‌های فایل مجاز هستند. Docker محتوای فایل را اعتبارسنجی می‌کند. فایل‌های بارگذاری‌شده برای تلاش مجدد جهت وارد کردن یا حذف دستی نگه داشته می‌شوند.', + imageUploadEmpty: 'فایل خالی است. یک فایل ایمیج معتبر انتخاب کنید.', + imageImportSubmitted: 'وظیفه وارد کردن ایمیج ثبت شد. برای مشاهده نتیجه، گزارش وظیفه را بررسی کنید.', importImage: 'واردات', buildArgs: 'آرگومان‌های ساخت', imageBuild: 'ساخت', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 7b762169d..d9e5371f1 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1859,6 +1859,11 @@ const message = { imageName: '画像名', pull: '引く', path: 'パス', + imageUploadDrop: 'イメージファイルをここにドラッグするか、クリックしてアップロードしてください', + imageUploadHelper: + 'ファイル形式に制限はありません。Docker が内容を検証します。アップロードしたファイルは保持されるため、インポートを再試行したり、手動で削除したりできます。', + imageUploadEmpty: 'ファイルが空です。有効なイメージファイルを選択してください。', + imageImportSubmitted: 'イメージのインポートタスクを送信しました。結果はタスクログで確認してください。', importImage: '輸入', buildArgs: 'ビルド引数', imageBuild: '建てる', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index df29a257b..e401cd5b0 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1841,6 +1841,11 @@ const message = { imageName: '이미지 이름', pull: '풀', path: '경로', + imageUploadDrop: '이미지 파일을 여기로 끌어 놓거나 클릭하여 업로드하세요', + imageUploadHelper: + '파일 형식에는 제한이 없습니다. Docker가 내용을 검증합니다. 업로드한 파일은 보관되므로 가져오기를 다시 시도하거나 수동으로 삭제할 수 있습니다.', + imageUploadEmpty: '빈 파일입니다. 유효한 이미지 파일을 선택하세요.', + imageImportSubmitted: '이미지 가져오기 작업이 제출되었습니다. 작업 로그에서 결과를 확인하세요.', importImage: '가져오기', buildArgs: '빌드 인수', imageBuild: '이미지 빌드', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 18531a70d..7372f0a12 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -1831,6 +1831,11 @@ const message = { imageName: 'ຊື່ Image', pull: 'ດຶງຂໍ້ມູນ (Pull)', path: 'ເສັ້ນທາງ', + imageUploadDrop: 'ລາກໄຟລ໌ອິມເມຈມາວາງບ່ອນນີ້ ຫຼື ຄລິກເພື່ອອັບໂຫຼດ', + imageUploadHelper: + 'ບໍ່ຈຳກັດຮູບແບບໄຟລ໌. Docker ຈະກວດສອບເນື້ອຫາ. ໄຟລ໌ທີ່ອັບໂຫຼດຈະຖືກເກັບໄວ້ ເພື່ອໃຫ້ສາມາດລອງນຳເຂົ້າອີກຄັ້ງ ຫຼື ລຶບດ້ວຍຕົນເອງ.', + imageUploadEmpty: 'ໄຟລ໌ຫວ່າງເປົ່າ. ກະລຸນາເລືອກໄຟລ໌ອິມເມຈທີ່ຖືກຕ້ອງ.', + imageImportSubmitted: 'ສົ່ງວຽກນຳເຂົ້າອິມເມຈແລ້ວ. ກະລຸນາເບິ່ງບັນທຶກວຽກເພື່ອກວດສອບຜົນ.', importImage: 'ນຳເຂົ້າ', buildArgs: 'Build Arguments', imageBuild: 'ສ້າງ (Build)', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 5826c02b8..b5004467a 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1891,6 +1891,11 @@ const message = { imageName: 'Nama imej', pull: 'Tarik', path: 'Laluan', + imageUploadDrop: 'Seret fail imej ke sini atau klik untuk memuat naik', + imageUploadHelper: + 'Semua format fail dibenarkan. Docker mengesahkan kandungannya. Fail yang dimuat naik disimpan untuk mencuba semula import atau dipadam secara manual.', + imageUploadEmpty: 'Fail kosong. Pilih arkib imej yang sah.', + imageImportSubmitted: 'Tugas import imej telah dihantar. Semak log tugas untuk melihat hasilnya.', importImage: 'Import', buildArgs: 'Argumen Binaan', imageBuild: 'Bina', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 4788d4ae2..3c555a419 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1893,6 +1893,11 @@ const message = { imageName: 'Nome da imagem', pull: 'Puxar', path: 'Caminho', + imageUploadDrop: 'Arraste um arquivo de imagem para cá ou clique para enviar', + imageUploadHelper: + 'Qualquer formato de arquivo é permitido. O Docker valida o conteúdo. Os arquivos enviados são mantidos para novas tentativas de importação ou exclusão manual.', + imageUploadEmpty: 'O arquivo está vazio. Selecione um arquivo de imagem válido.', + imageImportSubmitted: 'Tarefa de importação de imagem enviada. Consulte o log da tarefa para ver o resultado.', importImage: 'Importar', buildArgs: 'Argumentos de Build', imageBuild: 'Construção de imagem', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 0d39c78ec..932ed94d5 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1883,6 +1883,11 @@ const message = { imageName: 'Имя образа', pull: 'Загрузить', path: 'Путь', + imageUploadDrop: 'Перетащите файл образа сюда или нажмите для загрузки', + imageUploadHelper: + 'Допускаются файлы любого формата. Docker проверяет содержимое. Загруженные файлы сохраняются для повторного импорта или удаления вручную.', + imageUploadEmpty: 'Файл пуст. Выберите корректный архив образа.', + imageImportSubmitted: 'Задача импорта образа отправлена. Результат можно посмотреть в журнале задачи.', importImage: 'Импорт', buildArgs: 'Аргументы Сборки', imageBuild: 'Сборка', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 0a8d112f2..28447f928 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1892,6 +1892,11 @@ const message = { imageName: 'İmaj adı', pull: 'Çek', path: 'Yol', + imageUploadDrop: 'Bir imaj dosyasını buraya sürükleyin veya yüklemek için tıklayın', + imageUploadHelper: + 'Tüm dosya biçimlerine izin verilir. Docker içeriği doğrular. Yüklenen dosyalar, içe aktarmayı yeniden denemek veya elle silmek için saklanır.', + imageUploadEmpty: 'Dosya boş. Geçerli bir imaj arşivi seçin.', + imageImportSubmitted: 'İmajı içe aktarma görevi gönderildi. Sonuç için görev günlüğünü kontrol edin.', importImage: 'İçe aktar', buildArgs: 'Derleme Argümanları', imageBuild: 'Oluştur', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 217de952e..58ce851dd 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1781,6 +1781,10 @@ const message = { delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?', pull: '拉取', path: '路徑', + imageUploadDrop: '拖曳映像檔案至此處,或點擊上傳', + imageUploadHelper: '不限制檔案格式,由 Docker 驗證內容。上傳的檔案會保留,可重試匯入或手動刪除。', + imageUploadEmpty: '不能上傳空檔案,請選擇有效的映像檔案。', + imageImportSubmitted: '映像匯入任務已提交,請查看任務日誌', importImage: '匯入映像', imageBuild: '構建映像', buildArgs: '構建參數', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 181bada3a..716290730 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1801,6 +1801,10 @@ const message = { delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?', pull: '拉取', path: '路径', + imageUploadDrop: '拖拽镜像文件到此处,或点击上传', + imageUploadHelper: '不限制文件格式,由 Docker 校验内容。上传的文件会保留,可重试导入或手动删除。', + imageUploadEmpty: '不能上传空文件,请选择有效的镜像文件。', + imageImportSubmitted: '镜像导入任务已提交,请查看任务日志', importImage: '导入镜像', imageBuild: '构建镜像', buildArgs: '构建参数', diff --git a/frontend/src/utils/chunk-upload.ts b/frontend/src/utils/chunk-upload.ts new file mode 100644 index 000000000..e85c755a7 --- /dev/null +++ b/frontend/src/utils/chunk-upload.ts @@ -0,0 +1,57 @@ +import { chunkUploadFileData, type FileUploadRequestConfig } from '@/api/modules/files'; + +export const CHUNK_SIZE = 5 * 1024 * 1024; +const MAX_CHUNK_RETRIES = 3; + +const shouldRetryChunkUpload = (error: unknown) => { + const item = error as { + code?: string | number; + data?: { retryable?: boolean }; + response?: { status?: number }; + }; + if (item?.code === 'ERR_CANCELED') { + return false; + } + if (typeof item?.data?.retryable === 'boolean') { + return item.data.retryable; + } + const status = item?.response?.status ?? (typeof item?.code === 'number' ? item.code : undefined); + return !status || status === 408 || status === 429 || status >= 500; +}; + +const waitForChunkRetry = (attempt: number, signal: AbortSignal) => { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = window.setTimeout(done, 500 * 2 ** attempt); + function done() { + window.clearTimeout(timer); + signal.removeEventListener('abort', done); + resolve(); + } + signal.addEventListener('abort', done, { once: true }); + }); +}; + +export const uploadChunkWithRetry = async ( + formData: FormData, + config: FileUploadRequestConfig, + signal: AbortSignal, +) => { + let retryCount = 0; + while (true) { + signal.throwIfAborted(); + try { + await chunkUploadFileData(formData, config); + return; + } catch (error) { + if (signal.aborted || retryCount >= MAX_CHUNK_RETRIES || !shouldRetryChunkUpload(error)) { + throw error; + } + await waitForChunkRetry(retryCount, signal); + retryCount++; + } + } +}; diff --git a/frontend/src/views/container/image/load/index.vue b/frontend/src/views/container/image/load/index.vue index 46da2edba..2f45b7f4b 100644 --- a/frontend/src/views/container/image/load/index.vue +++ b/frontend/src/views/container/image/load/index.vue @@ -1,105 +1,254 @@ diff --git a/frontend/src/views/host/file-management/upload/index.vue b/frontend/src/views/host/file-management/upload/index.vue index 33749155b..06b324032 100644 --- a/frontend/src/views/host/file-management/upload/index.vue +++ b/frontend/src/views/host/file-management/upload/index.vue @@ -121,13 +121,8 @@