feat: Support application migration to other nodes. (#10832)

Refs https://github.com/1Panel-dev/1Panel/issues/10509
This commit is contained in:
CityFun 2025-10-31 18:34:20 +08:00 committed by GitHub
parent e86bc9a8db
commit 18ab07ec4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 133 additions and 28 deletions

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -58,6 +58,7 @@ const (
TaskScopeSystem = "System"
TaskScopeScript = "Script"
TaskScopeNodeFile = "NodeFile"
TaskScopeAppBackup = "AppBackup"
TaskScopeCluster = "Cluster"
)

View file

@ -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"

View file

@ -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"

View file

@ -36,6 +36,11 @@ ErrInternalServerKey: "サーバー内部エラー: "
# app
CustomAppStoreFileValid: "アプリストアパッケージは .tar.gz 形式である必要があります"
ErrFileNotFound: "{{ .name }} ファイルが存在しません"
AppBackup: 'アプリケーションバックアップ',
AppBackupPush: 'アプリケーションバックアップファイル {{.file}} をノード {{ .name }} に転送',
ErrSourceTargetSame: 'ソースノードとターゲットノードは同じにできません!',
AppInstall: 'ノード {{ .targetNode }} にアプリケーション {{ .name }} をインストール',
AppInstallCheck: 'アプリケーションインストール環境を確認',
# backup
ErrBackupInUsed: "このバックアップアカウントはスケジュールタスクで使用されており、削除できません"

View file

@ -36,6 +36,11 @@ ErrInternalServerKey: "서버 내부 오류:"
# app
CustomAppStoreFileValid: "앱 스토어 패키지는 .tar.gz 형식이어야 합니다"
ErrFileNotFound: "{{ .name }} 파일이 존재하지 않습니다"
AppBackup: '애플리케이션 백업',
AppBackupPush: '애플리케이션 백업 파일 {{.file}}을(를) 노드 {{ .name }}(으)로 전송',
ErrSourceTargetSame: '소스 노드와 대상 노드는 동일할 수 없습니다!',
AppInstall: '노드 {{ .targetNode }}에 애플리케이션 {{ .name }} 설치',
AppInstallCheck: '애플리케이션 설치 환경 확인',
# backup
ErrBackupInUsed: "이 백업 계정은 예약된 작업에 사용 중이며 삭제할 수 없습니다"

View file

@ -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"

View file

@ -36,6 +36,11 @@ ErrInternalServerKey: "Внутренняя ошибка сервера:"
# app
CustomAppStoreFileValid: "Пакет магазина приложений должен быть в формате .tar.gz"
ErrFileNotFound: "Файл {{ .name }} не найден"
AppBackup: 'Резервная копия приложения',
AppBackupPush: 'Передать файл резервной копии приложения {{.file}} на узел {{ .name }}',
ErrSourceTargetSame: 'Исходный узел и целевой узел не могут быть одинаковыми!',
AppInstall: 'Установить приложение {{ .name }} на узел {{ .targetNode }}',
AppInstallCheck: 'Проверить среду установки приложения',
# backup
ErrBackupInUsed: "Эта учетная запись резервного копирования используется в запланированных задачах и не может быть удалена"

View file

@ -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"

View file

@ -36,6 +36,11 @@ ErrInternalServerKey: "服務內部錯誤: "
#app
CustomAppStoreFileValid: "應用商店包需要 .tar.gz 格式"
ErrFileNotFound: "{{ .name }} 檔案不存在"
AppBackup: '應用備份',
AppBackupPush: '傳輸應用備份文件 {{.file}} 到節點 {{ .name }}',
ErrSourceTargetSame: '源節點和目標節點不能相同!',
AppInstall: '在 {{ .targetNode }} 節點安裝應用 {{ .name }}',
AppInstallCheck: '檢查應用安裝環境',
#backup
ErrBackupInUsed: "該備份帳號已在排程任務中使用,無法刪除"

View file

@ -36,6 +36,11 @@ ErrInternalServerKey: "服务内部错误:"
#app
CustomAppStoreFileValid: "应用商店包需要 .tar.gz 格式"
ErrFileNotFound: '{{ .name }} 文件不存在'
AppBackup: "应用备份"
AppBackupPush: "传输应用备份文件 {{.file}} 到节点 {{ .name }}"
ErrSourceTargetSame: "源节点和目标节点不能相同!"
AppInstall: "在 {{ .targetNode }} 节点安装应用 {{ .name }}"
AppInstallCheck: "检查应用安装环境"
#backup
ErrBackupInUsed: "该备份账号已在计划任务中使用,无法删除"

View file

@ -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)
}
}

View file

@ -150,6 +150,7 @@ export namespace App {
favorite: boolean;
app: App;
webUI: string;
appKey?: string;
}
export interface AppInstalledInfo {

View file

@ -128,6 +128,7 @@
<OpDialog ref="opRef" @search="search" />
<TaskLog ref="taskLogRef" @close="search" />
<PushApp ref="pushAppRef" v-if="isProductPro" />
</template>
<script lang="ts" setup>
@ -147,14 +148,24 @@ import i18n from '@/lang';
import { Backup } from '@/api/interface/backup';
import { MsgSuccess } from '@/utils/message';
import TaskLog from '@/components/log/task/index.vue';
import { GlobalStore } from '@/store';
import { routerToFileWithPath } from '@/utils/router';
const globalStore = GlobalStore();
import { useGlobalStore } from '@/composables/useGlobalStore';
const { isProductPro, currentNode } = useGlobalStore();
const PushApp = defineAsyncComponent(async () => {
const modules = import.meta.glob('@/xpack/views/appstore/push-app/index.vue');
const loader = modules['/src/xpack/views/appstore/push-app/index.vue'];
if (loader) {
return ((await loader()) as any).default;
}
return { template: '<div></div>' };
});
const selects = ref<any>([]);
const loading = ref();
const opRef = ref();
const taskLogRef = ref();
const pushAppRef = ref();
const data = ref();
const paginationConfig = reactive({
@ -175,16 +186,19 @@ const description = ref();
const open = ref();
const isBackup = ref();
const recordInfo = ref();
const appInstallID = ref();
interface DialogProps {
type: string;
name: string;
detailName: string;
status: string;
appInstallID?: number;
}
const acceptParams = (params: DialogProps): void => {
type.value = params.type;
if (type.value === 'app') {
appInstallID.value = params.appInstallID || 0;
loadBackupDir();
}
name.value = params.name;
@ -365,7 +379,7 @@ const onDownload = async (row: Backup.RecordInfo) => {
fileName: row.fileName,
};
await downloadBackupRecord(params).then(async (res) => {
downloadFile(res.data, globalStore.currentNode);
downloadFile(res.data, currentNode.value);
});
};
@ -412,6 +426,21 @@ const buttons = [
onRecover(row);
},
},
{
label: i18n.global.t('commons.button.migrate'),
disabled: (row: any) => {
return row.size === 0 || row.status === 'Failed' || row.accountType !== 'LOCAL';
},
show: () => {
return type.value === 'app';
},
click: (row: Backup.RecordInfo) => {
pushAppRef.value.acceptParams({
appInstallID: appInstallID.value,
appBackupID: row.id,
});
},
},
{
label: i18n.global.t('commons.button.download'),
disabled: (row: any) => {

View file

@ -86,6 +86,7 @@ const message = {
show: 'Show',
hide: 'Hide',
visit: 'Visit',
migrate: 'Migrate',
},
operate: {
start: 'Start',

View file

@ -85,6 +85,7 @@ const message = {
sure: 'Confirmar',
show: 'Mostrar',
hide: 'Ocultar',
migrate: 'Migrar',
},
operate: {
start: 'Iniciar',

View file

@ -83,6 +83,7 @@ const message = {
show: '表示する',
hide: '隠す',
visit: '訪問',
migrate: '移行',
},
operate: {
start: '開始',

View file

@ -83,6 +83,7 @@ const message = {
show: '보기',
hide: '숨기기',
visit: '방문',
migrate: '마이그레이션',
},
operate: {
start: '시작',

View file

@ -83,6 +83,7 @@ const message = {
show: 'Tunjukkan',
hide: 'Sembunyikan',
visit: 'Lawati',
migrate: 'Migrasi',
},
operate: {
start: 'Mula',

View file

@ -83,6 +83,7 @@ const message = {
show: 'Exibir',
hide: 'Ocultar',
visit: 'Visitar',
migrate: 'Migrar',
},
operate: {
start: 'Iniciar',

View file

@ -83,6 +83,7 @@ const message = {
show: 'Показать',
hide: 'Скрыть',
visit: 'Посетить',
migrate: 'Мигрировать',
},
operate: {
start: 'Запустить',

View file

@ -86,6 +86,7 @@ const message = {
show: 'Göster',
hide: 'Gizle',
visit: 'Visit',
migrate: 'Taşı',
},
operate: {
start: 'Başlat',

View file

@ -86,6 +86,7 @@ const message = {
show: '顯示',
hide: '隱藏',
visit: '訪問',
migrate: '遷移',
},
operate: {
start: '啟動',

View file

@ -86,6 +86,7 @@ const message = {
show: '显示',
hide: '隐藏',
visit: '访问',
migrate: '迁移',
},
operate: {
start: '启动',

View file

@ -211,13 +211,7 @@
plain
round
size="small"
@click="
openBackups(
installed.appKey,
installed.name,
installed.status,
)
"
@click="openBackups(installed)"
v-if="mode === 'installed'"
>
{{ $t('commons.button.backup') }}
@ -709,12 +703,13 @@ const toContainer = async (row: App.AppInstalled) => {
routerToNameWithQuery('ContainerItem', { filters: 'com.docker.compose.project=' + row.name, uncached: true });
};
const openBackups = (key: string, name: string, status: string) => {
const openBackups = (row: App.AppInstalled) => {
let params = {
type: 'app',
name: key,
detailName: name,
status: status,
name: row.appKey,
detailName: row.name,
status: row.status,
appInstallID: row.id,
};
backupRef.value.acceptParams(params);
};