From 18ab07ec4fcb231ffac0b9dac271becf9253922a Mon Sep 17 00:00:00 2001
From: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
Date: Fri, 31 Oct 2025 18:34:20 +0800
Subject: [PATCH] feat: Support application migration to other nodes. (#10832)
Refs https://github.com/1Panel-dev/1Panel/issues/10509
---
agent/app/dto/response/app.go | 2 ++
agent/app/repo/app.go | 9 -----
agent/app/repo/backup.go | 3 +-
agent/app/service/app_install.go | 18 ++++++++++
core/app/task/task.go | 9 ++---
core/i18n/lang/en.yaml | 5 +++
core/i18n/lang/es-ES.yaml | 5 +++
core/i18n/lang/ja.yaml | 5 +++
core/i18n/lang/ko.yaml | 5 +++
core/i18n/lang/pt-BR.yaml | 5 +++
core/i18n/lang/ru.yaml | 5 +++
core/i18n/lang/tr.yaml | 5 +++
core/i18n/lang/zh-Hant.yaml | 5 +++
core/i18n/lang/zh.yaml | 5 +++
core/utils/req_helper/requset.go | 12 +++++++
frontend/src/api/interface/app.ts | 1 +
frontend/src/components/backup/index.vue | 35 +++++++++++++++++--
frontend/src/lang/modules/en.ts | 1 +
frontend/src/lang/modules/es-es.ts | 1 +
frontend/src/lang/modules/ja.ts | 1 +
frontend/src/lang/modules/ko.ts | 1 +
frontend/src/lang/modules/ms.ts | 1 +
frontend/src/lang/modules/pt-br.ts | 1 +
frontend/src/lang/modules/ru.ts | 1 +
frontend/src/lang/modules/tr.ts | 1 +
frontend/src/lang/modules/zh-Hant.ts | 1 +
frontend/src/lang/modules/zh.ts | 1 +
.../src/views/app-store/installed/index.vue | 17 ++++-----
28 files changed, 133 insertions(+), 28 deletions(-)
diff --git a/agent/app/dto/response/app.go b/agent/app/dto/response/app.go
index d9966fe86..d23922813 100644
--- a/agent/app/dto/response/app.go
+++ b/agent/app/dto/response/app.go
@@ -139,6 +139,8 @@ type AppInstallInfo struct {
HttpPort int `json:"HttpPort"`
Container string `json:"container"`
ComposePath string `json:"composePath"`
+ AppKey string `json:"appKey"`
+ AppPorts []int `json:"appPorts"`
Env map[string]interface{} `json:"env"`
}
diff --git a/agent/app/repo/app.go b/agent/app/repo/app.go
index 67bf3105b..331ed8e21 100644
--- a/agent/app/repo/app.go
+++ b/agent/app/repo/app.go
@@ -27,7 +27,6 @@ type IAppRepo interface {
GetFirst(opts ...DBOption) (model.App, error)
GetBy(opts ...DBOption) ([]model.App, error)
BatchCreate(ctx context.Context, apps []model.App) error
- GetByKey(ctx context.Context, key string) (model.App, error)
Create(ctx context.Context, app *model.App) error
Save(ctx context.Context, app *model.App) error
BatchDelete(ctx context.Context, apps []model.App) error
@@ -144,14 +143,6 @@ func (a AppRepo) BatchCreate(ctx context.Context, apps []model.App) error {
return getTx(ctx).Omit(clause.Associations).Create(&apps).Error
}
-func (a AppRepo) GetByKey(ctx context.Context, key string) (model.App, error) {
- var app model.App
- if err := getTx(ctx).Where("key = ?", key).First(&app).Error; err != nil {
- return app, err
- }
- return app, nil
-}
-
func (a AppRepo) Create(ctx context.Context, app *model.App) error {
return getTx(ctx).Omit(clause.Associations).Create(app).Error
}
diff --git a/agent/app/repo/backup.go b/agent/app/repo/backup.go
index 80213446a..2f726000c 100644
--- a/agent/app/repo/backup.go
+++ b/agent/app/repo/backup.go
@@ -20,6 +20,7 @@ type IBackupRepo interface {
WithByPublic(isPublic bool) DBOption
ListRecord(opts ...DBOption) ([]model.BackupRecord, error)
+ GetRecord(opts ...DBOption) (*model.BackupRecord, error)
PageRecord(page, size int, opts ...DBOption) (int64, []model.BackupRecord, error)
CreateRecord(record *model.BackupRecord) error
DeleteRecord(ctx context.Context, opts ...DBOption) error
@@ -160,7 +161,7 @@ func (u *BackupRepo) WithByCronID(cronjobID uint) DBOption {
}
func (u *BackupRepo) GetRecord(opts ...DBOption) (*model.BackupRecord, error) {
- var record *model.BackupRecord
+ record := &model.BackupRecord{}
db := global.DB.Model(&model.BackupRecord{})
for _, opt := range opts {
db = opt(db)
diff --git a/agent/app/service/app_install.go b/agent/app/service/app_install.go
index bf23626fb..031b8f311 100644
--- a/agent/app/service/app_install.go
+++ b/agent/app/service/app_install.go
@@ -940,8 +940,26 @@ func (a *AppInstallService) GetAppInstallInfo(installID uint) (*response.AppInst
HttpPort: appInstall.HttpPort,
Status: appInstall.Status,
Message: appInstall.Message,
+ AppKey: appInstall.App.Key,
Env: envMap,
ComposePath: appInstall.GetComposePath(),
}
+ for k, v := range envMap {
+ if strings.Contains(strings.ToUpper(k), "PANEL_APP_PORT") {
+ var port int
+ switch val := v.(type) {
+ case int:
+ port = val
+ case string:
+ port, err = strconv.Atoi(val)
+ if err != nil {
+ continue
+ }
+ default:
+ continue
+ }
+ res.AppPorts = append(res.AppPorts, port)
+ }
+ }
return res, nil
}
diff --git a/core/app/task/task.go b/core/app/task/task.go
index 4f2b6a1cc..546fd8686 100644
--- a/core/app/task/task.go
+++ b/core/app/task/task.go
@@ -55,10 +55,11 @@ const (
)
const (
- TaskScopeSystem = "System"
- TaskScopeScript = "Script"
- TaskScopeNodeFile = "NodeFile"
- TaskScopeCluster = "Cluster"
+ TaskScopeSystem = "System"
+ TaskScopeScript = "Script"
+ TaskScopeNodeFile = "NodeFile"
+ TaskScopeAppBackup = "AppBackup"
+ TaskScopeCluster = "Cluster"
)
func GetTaskName(resourceName, operate, scope string) string {
diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml
index e4c3f69ef..d8f0e62be 100644
--- a/core/i18n/lang/en.yaml
+++ b/core/i18n/lang/en.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "Internal server error:"
# app
CustomAppStoreFileValid: "Application store package requires .tar.gz format"
ErrFileNotFound: "{{ .name }} file does not exist"
+AppBackup: 'Application backup',
+AppBackupPush: 'Transfer application backup file {{.file}} to node {{ .name }}',
+ErrSourceTargetSame: 'Source node and target node cannot be the same!',
+AppInstall: 'Install application {{ .name }} on node {{ .targetNode }}',
+AppInstallCheck: 'Check application installation environment',
# backup
ErrBackupInUsed: "This backup account is used in scheduled tasks and cannot be deleted"
diff --git a/core/i18n/lang/es-ES.yaml b/core/i18n/lang/es-ES.yaml
index d2a455e20..c6f6a3b70 100644
--- a/core/i18n/lang/es-ES.yaml
+++ b/core/i18n/lang/es-ES.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "Error interno del servidor:"
# app
CustomAppStoreFileValid: "El paquete de la tienda de aplicaciones debe tener formato .tar.gz"
ErrFileNotFound: "El archivo {{ .name }} no existe"
+AppBackup: 'Copia de seguridad de aplicación',
+AppBackupPush: 'Transferir archivo de copia de seguridad de aplicación {{.file}} al nodo {{ .name }}',
+ErrSourceTargetSame: '¡El nodo de origen y el nodo de destino no pueden ser el mismo!',
+AppInstall: 'Instalar aplicación {{ .name }} en nodo {{ .targetNode }}',
+AppInstallCheck: 'Verificar entorno de instalación de aplicación',
# backup
ErrBackupInUsed: "Esta cuenta de respaldo se utiliza en tareas programadas y no se puede eliminar"
diff --git a/core/i18n/lang/ja.yaml b/core/i18n/lang/ja.yaml
index d64f34023..01991ee98 100644
--- a/core/i18n/lang/ja.yaml
+++ b/core/i18n/lang/ja.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "サーバー内部エラー: "
# app
CustomAppStoreFileValid: "アプリストアパッケージは .tar.gz 形式である必要があります"
ErrFileNotFound: "{{ .name }} ファイルが存在しません"
+AppBackup: 'アプリケーションバックアップ',
+AppBackupPush: 'アプリケーションバックアップファイル {{.file}} をノード {{ .name }} に転送',
+ErrSourceTargetSame: 'ソースノードとターゲットノードは同じにできません!',
+AppInstall: 'ノード {{ .targetNode }} にアプリケーション {{ .name }} をインストール',
+AppInstallCheck: 'アプリケーションインストール環境を確認',
# backup
ErrBackupInUsed: "このバックアップアカウントはスケジュールタスクで使用されており、削除できません"
diff --git a/core/i18n/lang/ko.yaml b/core/i18n/lang/ko.yaml
index 4250775a0..67cbb87d7 100644
--- a/core/i18n/lang/ko.yaml
+++ b/core/i18n/lang/ko.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "서버 내부 오류:"
# app
CustomAppStoreFileValid: "앱 스토어 패키지는 .tar.gz 형식이어야 합니다"
ErrFileNotFound: "{{ .name }} 파일이 존재하지 않습니다"
+AppBackup: '애플리케이션 백업',
+AppBackupPush: '애플리케이션 백업 파일 {{.file}}을(를) 노드 {{ .name }}(으)로 전송',
+ErrSourceTargetSame: '소스 노드와 대상 노드는 동일할 수 없습니다!',
+AppInstall: '노드 {{ .targetNode }}에 애플리케이션 {{ .name }} 설치',
+AppInstallCheck: '애플리케이션 설치 환경 확인',
# backup
ErrBackupInUsed: "이 백업 계정은 예약된 작업에 사용 중이며 삭제할 수 없습니다"
diff --git a/core/i18n/lang/pt-BR.yaml b/core/i18n/lang/pt-BR.yaml
index 4dba30ec6..8fcf47b14 100644
--- a/core/i18n/lang/pt-BR.yaml
+++ b/core/i18n/lang/pt-BR.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "Erro interno do servidor:"
# app
CustomAppStoreFileValid: "O pacote da loja de aplicativos deve estar no formato .tar.gz"
ErrFileNotFound: "Arquivo {{ .name }} não encontrado"
+AppBackup: 'Backup de aplicação',
+AppBackupPush: 'Transferir arquivo de backup de aplicação {{.file}} para o nó {{ .name }}',
+ErrSourceTargetSame: 'O nó de origem e o nó de destino não podem ser os mesmos!',
+AppInstall: 'Instalar aplicação {{ .name }} no nó {{ .targetNode }}',
+AppInstallCheck: 'Verificar ambiente de instalação da aplicação',
# backup
ErrBackupInUsed: "Esta conta de backup está em uso em tarefas agendadas e não pode ser excluída"
diff --git a/core/i18n/lang/ru.yaml b/core/i18n/lang/ru.yaml
index c7a4d4ec4..a929cf09a 100644
--- a/core/i18n/lang/ru.yaml
+++ b/core/i18n/lang/ru.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "Внутренняя ошибка сервера:"
# app
CustomAppStoreFileValid: "Пакет магазина приложений должен быть в формате .tar.gz"
ErrFileNotFound: "Файл {{ .name }} не найден"
+AppBackup: 'Резервная копия приложения',
+AppBackupPush: 'Передать файл резервной копии приложения {{.file}} на узел {{ .name }}',
+ErrSourceTargetSame: 'Исходный узел и целевой узел не могут быть одинаковыми!',
+AppInstall: 'Установить приложение {{ .name }} на узел {{ .targetNode }}',
+AppInstallCheck: 'Проверить среду установки приложения',
# backup
ErrBackupInUsed: "Эта учетная запись резервного копирования используется в запланированных задачах и не может быть удалена"
diff --git a/core/i18n/lang/tr.yaml b/core/i18n/lang/tr.yaml
index b8a15520e..c6b311f39 100644
--- a/core/i18n/lang/tr.yaml
+++ b/core/i18n/lang/tr.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "İç sunucu hatası:"
# app
CustomAppStoreFileValid: "Uygulama mağazası paketi .tar.gz formatında olmalıdır"
ErrFileNotFound: "{{ .name }} dosyası mevcut değil"
+AppBackup: 'Uygulama yedekleme',
+AppBackupPush: 'Uygulama yedek dosyası {{.file}} düğüm {{ .name }} a aktar',
+ErrSourceTargetSame: 'Kaynak düğüm ve hedef düğüm aynı olamaz!',
+AppInstall: 'Düğüm {{ .targetNode }} üzerine {{ .name }} uygulamasını yükle',
+AppInstallCheck: 'Uygulama kurulum ortamını kontrol et',
# backup
ErrBackupInUsed: "Bu yedekleme hesabı zamanlanmış görevlerde kullanılıyor ve silinemez"
diff --git a/core/i18n/lang/zh-Hant.yaml b/core/i18n/lang/zh-Hant.yaml
index 51106d6a4..191d60240 100644
--- a/core/i18n/lang/zh-Hant.yaml
+++ b/core/i18n/lang/zh-Hant.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "服務內部錯誤: "
#app
CustomAppStoreFileValid: "應用商店包需要 .tar.gz 格式"
ErrFileNotFound: "{{ .name }} 檔案不存在"
+AppBackup: '應用備份',
+AppBackupPush: '傳輸應用備份文件 {{.file}} 到節點 {{ .name }}',
+ErrSourceTargetSame: '源節點和目標節點不能相同!',
+AppInstall: '在 {{ .targetNode }} 節點安裝應用 {{ .name }}',
+AppInstallCheck: '檢查應用安裝環境',
#backup
ErrBackupInUsed: "該備份帳號已在排程任務中使用,無法刪除"
diff --git a/core/i18n/lang/zh.yaml b/core/i18n/lang/zh.yaml
index b74b156e1..da97ba7ba 100644
--- a/core/i18n/lang/zh.yaml
+++ b/core/i18n/lang/zh.yaml
@@ -36,6 +36,11 @@ ErrInternalServerKey: "服务内部错误:"
#app
CustomAppStoreFileValid: "应用商店包需要 .tar.gz 格式"
ErrFileNotFound: '{{ .name }} 文件不存在'
+AppBackup: "应用备份"
+AppBackupPush: "传输应用备份文件 {{.file}} 到节点 {{ .name }}"
+ErrSourceTargetSame: "源节点和目标节点不能相同!"
+AppInstall: "在 {{ .targetNode }} 节点安装应用 {{ .name }}"
+AppInstallCheck: "检查应用安装环境"
#backup
ErrBackupInUsed: "该备份账号已在计划任务中使用,无法删除"
diff --git a/core/utils/req_helper/requset.go b/core/utils/req_helper/requset.go
index 776093dc1..9d15d13f8 100644
--- a/core/utils/req_helper/requset.go
+++ b/core/utils/req_helper/requset.go
@@ -4,6 +4,10 @@ import (
"context"
"crypto/tls"
"errors"
+ "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local"
+ "github.com/1Panel-dev/1Panel/core/xpack/app/model"
+ "github.com/1Panel-dev/1Panel/core/xpack/utils/node_helper"
+ "github.com/gin-gonic/gin"
"io"
"net"
"net/http"
@@ -111,3 +115,11 @@ func handleGetWithTransport(url string, transport *http.Transport) (*http.Respon
return resp, nil
}
+
+func RequestToNode(ctx *gin.Context, reqUrl, reqMethod string, body io.Reader, isLocal bool, timeout int, node model.Node) (res interface{}, resErr error) {
+ if isLocal {
+ return proxy_local.NewLocalClient(reqUrl, reqMethod, body, ctx)
+ } else {
+ return node_helper.NewRequestToAgent(reqUrl, reqMethod, body, timeout, node)
+ }
+}
diff --git a/frontend/src/api/interface/app.ts b/frontend/src/api/interface/app.ts
index 0e3731d0a..c66dd4974 100644
--- a/frontend/src/api/interface/app.ts
+++ b/frontend/src/api/interface/app.ts
@@ -150,6 +150,7 @@ export namespace App {
favorite: boolean;
app: App;
webUI: string;
+ appKey?: string;
}
export interface AppInstalledInfo {
diff --git a/frontend/src/components/backup/index.vue b/frontend/src/components/backup/index.vue
index 43994da8d..16d8ce510 100644
--- a/frontend/src/components/backup/index.vue
+++ b/frontend/src/components/backup/index.vue
@@ -128,6 +128,7 @@
+