feat: Add MySQL master-slave installation feature (#9505)

This commit is contained in:
CityFun 2025-07-13 22:36:21 +08:00 committed by GitHub
parent 7d5f0aa906
commit c391177d34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 295 additions and 56 deletions

View file

@ -327,3 +327,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
}
helper.Success(c)
}
// @Tags App
// @Summary Get app install info
// @Accept json
// @Param appInstallId path integer true "App install id"
// @Success 200 {object} dto.AppInstallInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}

View file

@ -129,6 +129,19 @@ type AppInstallDTO struct {
Container string `json:"container"`
}
type AppInstallInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Status string `json:"status"`
Message string `json:"message"`
HttpPort int `json:"HttpPort"`
Container string `json:"container"`
ComposePath string `json:"composePath"`
Env map[string]interface{} `json:"env"`
}
type DatabaseConn struct {
Status string `json:"status"`
Username string `json:"username"`

View file

@ -56,6 +56,7 @@ type IAppInstallService interface {
UpdateAppConfig(req request.AppConfigUpdate) error
GetInstallList() ([]dto.AppInstallInfo, error)
GetAppInstallInfo(appInstallID uint) (*response.AppInstallInfo, error)
}
func NewIAppInstalledService() IAppInstallService {
@ -898,3 +899,29 @@ func updateInstallInfoInDB(appKey, appName, param string, value interface{}) err
}
return nil
}
func (a *AppInstallService) GetAppInstallInfo(installID uint) (*response.AppInstallInfo, error) {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(installID))
if appInstall.ID == 0 {
return &response.AppInstallInfo{
Status: constant.StatusDeleted,
}, nil
}
var envMap map[string]interface{}
err := json.Unmarshal([]byte(appInstall.Env), &envMap)
if err != nil {
return nil, err
}
res := &response.AppInstallInfo{
ID: appInstall.ID,
Name: appInstall.Name,
Version: appInstall.Version,
Container: appInstall.ContainerName,
HttpPort: appInstall.HttpPort,
Status: appInstall.Status,
Message: appInstall.Message,
Env: envMap,
ComposePath: appInstall.GetComposePath(),
}
return res, nil
}

View file

@ -974,8 +974,8 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
logStr := i18n.GetWithName("ExecShell", operate)
task.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(10*time.Minute), cmd.WithScriptPath(scriptPath), cmd.WithWorkDir(workDir))
out, err := cmdMgr.RunWithStdout("bash")
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(10*time.Minute), cmd.WithWorkDir(workDir))
out, err := cmdMgr.RunWithStdoutBashCf(scriptPath)
if err != nil {
if out != "" {
err = errors.New(out)

View file

@ -38,6 +38,7 @@ func (a *AppRouter) InitRouter(Router *gin.RouterGroup) {
appRouter.POST("/installed/params/update", baseApi.UpdateInstalled)
appRouter.POST("/installed/update/versions", baseApi.GetUpdateVersions)
appRouter.POST("/installed/config/update", baseApi.UpdateAppConfig)
appRouter.GET("/installed/info/:appInstallId", baseApi.GetAppInstallInfo)
appRouter.POST("/installed/ignore", baseApi.IgnoreAppUpgrade)
appRouter.GET("/ignored/detail", baseApi.ListAppIgnored)

View file

@ -45,16 +45,19 @@ type SubTask struct {
}
const (
TaskUpgrade = "TaskUpgrade"
TaskAddNode = "TaskAddNode"
TaskSync = "TaskSync"
TaskRsync = "TaskRsync"
TaskUpgrade = "TaskUpgrade"
TaskAddNode = "TaskAddNode"
TaskSync = "TaskSync"
TaskRsync = "TaskRsync"
TaskInstallCluster = "TaskInstallCluster"
TaskCreateCluster = "TaskCreateCluster"
)
const (
TaskScopeSystem = "System"
TaskScopeScript = "Script"
TaskScopeSystem = "System"
TaskScopeScript = "Script"
TaskScopeNodeFile = "NodeFile"
TaskScopeCluster = "Cluster"
)
func GetTaskName(resourceName, operate, scope string) string {

View file

@ -176,3 +176,8 @@ var DynamicRoutes = []string{
var CertStore atomic.Value
var DaemonJsonPath = "/etc/docker/daemon.json"
const (
RoleMaster = "master"
RoleSlave = "slave"
)

View file

@ -19,4 +19,8 @@ const (
StatusEnable = "Enable"
StatusDisable = "Disable"
StatusInstalling = "Installing"
StatusNormal = "Normal"
StatusDeleted = "Deleted"
)

View file

@ -2,6 +2,10 @@ package helper
import (
"encoding/json"
"fmt"
"github.com/1Panel-dev/1Panel/core/app/model"
"gorm.io/gorm"
"strings"
"github.com/1Panel-dev/1Panel/core/app/dto"
)
@ -53,3 +57,41 @@ func LoadMenus() string {
menu, _ := json.Marshal(item)
return string(menu)
}
func AddMenu(newMenu dto.ShowMenu, parentMenuID string, tx *gorm.DB) error {
var menuJSON string
if err := tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Pluck("value", &menuJSON).Error; err != nil {
return err
}
if strings.Contains(menuJSON, fmt.Sprintf(`"%s"`, newMenu.Label)) && strings.Contains(menuJSON, fmt.Sprintf(`"%s"`, newMenu.Path)) {
return nil
}
var menus []dto.ShowMenu
if err := json.Unmarshal([]byte(menuJSON), &menus); err != nil {
return tx.Model(&model.Setting{}).
Where("key = ?", "HideMenu").
Update("value", LoadMenus()).Error
}
for i, menu := range menus {
if menu.ID == parentMenuID {
exists := false
for _, child := range menu.Children {
if child.ID == newMenu.ID {
exists = true
break
}
}
if !exists {
menus[i].Children = append([]dto.ShowMenu{newMenu}, menus[i].Children...)
}
break
}
}
updatedJSON, err := json.Marshal(menus)
if err != nil {
return tx.Model(&model.Setting{}).
Where("key = ?", "HideMenu").
Update("value", LoadMenus()).Error
}
return tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Update("value", string(updatedJSON)).Error
}

View file

@ -20,6 +20,7 @@ func Init() {
migrations.UpdateGoogle,
migrations.UpdateXpackHideMenu,
migrations.UpdateOnedrive,
migrations.AddClusterMenu,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View file

@ -489,3 +489,17 @@ var UpdateOnedrive = &gormigrate.Migration{
return nil
},
}
var AddClusterMenu = &gormigrate.Migration{
ID: "20250707-add-cluster-menu",
Migrate: func(tx *gorm.DB) error {
return helper.AddMenu(dto.ShowMenu{
ID: "120",
Disabled: false,
Title: "xpack.cluster.cluster",
IsShow: true,
Label: "Cluster",
Path: "/xpack/cluster",
}, "11", tx)
},
}

View file

@ -38,7 +38,7 @@
"codemirror": "^6.0.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.0",
"element-plus": "2.9.8",
"element-plus": "2.9.9",
"fit2cloud-ui-plus": "^1.2.2",
"highlight.js": "^11.9.0",
"js-base64": "^3.7.7",

View file

@ -151,6 +151,17 @@ export namespace App {
webUI: string;
}
export interface AppInstalledInfo {
id: number;
name: string;
version: string;
status: string;
message: string;
httpPort: number;
container: string;
env: { [key: string]: string };
}
export interface AppInstallDto {
id: number;
name: string;

View file

@ -19,6 +19,10 @@ export const getAppByKey = (key: string) => {
return http.get<App.AppDTO>('apps/' + key);
};
export const getAppByKeyWithNode = (key: string, node: string) => {
return http.get<App.AppDTO>('apps/' + key + `?operateNode=${node}`);
};
export const getAppTags = () => {
return http.get<App.Tag[]>('apps/tags');
};
@ -59,14 +63,19 @@ export const checkAppInstalled = (key: string, name: string) => {
return http.post<App.CheckInstalled>(`apps/installed/check`, { key: key, name: name });
};
export const appInstalledDeleteCheck = (appInstallId: number) => {
return http.get<App.AppInstallResource[]>(`apps/installed/delete/check/${appInstallId}`);
export const appInstalledDeleteCheck = (appInstallId: number, node?: string) => {
const params = node ? `?operateNode=${node}` : '';
return http.get<App.AppInstallResource[]>(`apps/installed/delete/check/${appInstallId}${params}`);
};
export const getAppInstalled = (search: App.AppInstalledSearch) => {
return http.post<ResPage<App.AppInstalled>>('apps/installed/search', search);
};
export const getAppInstalledByID = (installID: number, node: string) => {
return http.get<App.AppInstalledInfo>(`apps/installed/info/${installID}?operateNode=${node}`);
};
export const installedOp = (op: App.AppInstalledOp) => {
return http.post<any>('apps/installed/op', op, TimeoutEnum.T_40S);
};

View file

@ -126,8 +126,9 @@ export const loadMFA = (param: Setting.MFARequest) => {
export const bindMFA = (param: Setting.MFABind) => {
return http.post(`/core/settings/mfa/bind`, param);
};
export const getAppStoreConfig = () => {
return http.get<App.AppStoreConfig>(`/core/settings/apps/store/config`);
export const getAppStoreConfig = (node?: string) => {
const params = node ? `?operateNode=${node}` : '';
return http.get<App.AppStoreConfig>(`/core/settings/apps/store/config${params}`);
};
export const updateAppStoreConfig = (req: App.AppStoreConfigUpdate) => {
return http.post(`/core/settings/apps/store/update`, req);

View file

@ -16,6 +16,7 @@
:compose="compose"
:resource="resource"
:container="container"
:node="node"
:highlightDiff="highlightDiff"
/>
</template>
@ -36,11 +37,13 @@ const globalStore = GlobalStore();
const logVisible = ref(false);
const compose = ref('');
const highlightDiff = ref(320);
const node = ref('');
interface DialogProps {
compose: string;
resource: string;
container: string;
node: string;
}
const defaultProps = defineProps({
@ -75,6 +78,7 @@ const acceptParams = (props: DialogProps): void => {
compose.value = props.compose;
resource.value = props.resource;
container.value = props.container;
node.value = props.node;
open.value = true;
};

View file

@ -65,6 +65,10 @@ const props = defineProps({
type: Number,
default: 320,
},
node: {
type: String,
default: '',
},
});
const styleVars = computed(() => ({
@ -137,6 +141,9 @@ const searchLogs = async () => {
}
logs.value = [];
let currentNode = globalStore.currentNode;
if (props.node && props.node !== '') {
currentNode = props.node;
}
let url = `/api/v2/containers/search/log?container=${logSearch.container}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}&operateNode=${currentNode}`;
if (logSearch.compose !== '') {
url = `/api/v2/containers/search/log?compose=${logSearch.compose}&since=${logSearch.mode}&tail=${logSearch.tail}&follow=${logSearch.isWatch}&operateNode=${currentNode}`;

View file

@ -3628,6 +3628,14 @@ const message = {
exchange: 'File Exchange',
exchangeConfirm: "Do you want to transfer the file/folder {1} from {0} node to {2} node's {3} directory?",
},
cluster: {
cluster: 'Application High Availability',
name: 'Cluster Name',
addCluster: 'Add Cluster',
installNode: 'Install Node',
master: 'Master Node',
slave: 'Slave Node',
},
},
};

View file

@ -3491,6 +3491,14 @@ const message = {
exchange: 'ファイル交換',
exchangeConfirm: '{0} ノードのファイル/フォルダ {1} {2} ノードの {3} ディレクトリに転送しますか',
},
cluster: {
cluster: 'アプリケーションの高可用性',
name: 'クラスタ名',
addCluster: 'クラスタを追加',
installNode: 'ノードをインストール',
master: 'マスターノード',
slave: 'スレーブノード',
},
},
};
export default {

View file

@ -3429,6 +3429,14 @@ const message = {
exchange: '파일 교환',
exchangeConfirm: '{0} 노드의 파일/폴더 {1}() {2} 노드의 {3} 디렉토리로 전송하시겠습니까?',
},
cluster: {
cluster: '애플리케이션 고가용성',
name: '클러스터 이름',
addCluster: '클러스터 추가',
installNode: '노드 설치',
master: '마스터 노드',
slave: '슬레이브 노드',
},
},
};

View file

@ -3572,6 +3572,14 @@ const message = {
exchange: 'Pertukaran Fail',
exchangeConfirm: 'Adakah anda mahu memindahkan fail/folder {1} dari node {0} ke direktori {3} node {2}?',
},
cluster: {
cluster: 'Aplikasi Tinggi Ketersediaan',
name: 'Nama Kluster',
addCluster: 'Tambah Kluster',
installNode: 'Pasang Node',
master: 'Node Utama',
slave: 'Node Hamba',
},
},
};

View file

@ -3579,6 +3579,14 @@ const message = {
exchange: 'Troca de Arquivos',
exchangeConfirm: 'Deseja transferir o arquivo/pasta {1} do {0} para o diretório {3} do {2}?',
},
cluster: {
cluster: 'Alta Disponibilidade de Aplicações',
name: 'Nome do Cluster',
addCluster: 'Adicionar Cluster',
installNode: 'Instalar ',
master: ' Mestre',
slave: ' Escravo',
},
},
};

View file

@ -3570,6 +3570,14 @@ const message = {
exchange: 'Обмен файлами',
exchangeConfirm: 'Хотите перенести файл/папку {1} с узла {0} в каталог {3} узла {2}?',
},
cluster: {
cluster: 'Высокая доступность приложений',
name: 'Имя кластера',
addCluster: 'Добавить кластер',
installNode: 'Установить узел',
master: 'Главный узел',
slave: 'Подчиненный узел',
},
},
};

View file

@ -3669,6 +3669,14 @@ const message = {
exchange: 'Dosya Değişimi',
exchangeConfirm: '{0} düğümünden {1} dosya/klasörünü {2} düğümünün {3} dizinine aktarmak istiyor musunuz?',
},
cluster: {
cluster: 'Высокая доступность приложений',
name: 'Имя кластера',
addCluster: 'Добавить кластер',
installNode: 'Установить узел',
master: 'Главный узел',
slave: 'Подчиненный узел',
},
},
};

View file

@ -3377,6 +3377,14 @@ const message = {
exchange: '文件對傳',
exchangeConfirm: '是否將 {0} 節點文件/文件夾 {1} 傳輸到 {2} 節點 {3} 目錄',
},
cluster: {
cluster: '應用高可用',
name: '集群名稱',
addCluster: '添加集群',
installNode: '安裝節點',
master: '主節點',
slave: '從節點',
},
},
};
export default {

View file

@ -3357,6 +3357,14 @@ const message = {
exchange: '文件对传',
exchangeConfirm: '是否将 {0} 节点文件/文件夹 {1} 传输到 {2} 节点 {3} 目录',
},
cluster: {
cluster: '应用高可用',
name: '集群名称',
addCluster: '添加集群',
installNode: '安装节点',
master: '主节点',
slave: '从节点',
},
},
};
export default {

28
frontend/src/utils/app.ts Normal file
View file

@ -0,0 +1,28 @@
import { jumpToPath } from './util';
import router from '@/routers';
export const jumpToInstall = (type: string, key: string) => {
switch (type) {
case 'php':
case 'node':
case 'java':
case 'go':
case 'python':
case 'dotnet':
jumpToPath(router, '/websites/runtimes/' + type);
return true;
}
switch (key) {
case 'mysql-cluster':
console.log('jumpToInstall mysql-cluster');
jumpToPath(router, '/xpack/cluster/mysql');
return true;
case 'redis-cluster':
jumpToPath(router, '/xpack/cluster/redis');
return true;
case 'postgres-cluster':
jumpToPath(router, '/xpack/cluster/postgres');
return true;
}
return false;
};

View file

@ -132,13 +132,14 @@ import Install from '../detail/install/index.vue';
import router from '@/routers';
import { MsgSuccess } from '@/utils/message';
import { GlobalStore } from '@/store';
import { newUUID, jumpToPath } from '@/utils/util';
import { newUUID } from '@/utils/util';
import Detail from '../detail/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import { storeToRefs } from 'pinia';
import bus from '@/global/bus';
import Tags from '@/views/app-store/components/tag.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import { jumpToInstall } from '@/utils/app';
const globalStore = GlobalStore();
const { isProductPro } = storeToRefs(globalStore);
@ -208,20 +209,11 @@ const search = async (req: App.AppReq) => {
};
const openInstall = (app: App.App) => {
switch (app.type) {
case 'php':
case 'node':
case 'java':
case 'go':
case 'python':
case 'dotnet':
jumpToPath(router, '/websites/runtimes/' + app.type);
break;
default:
const params = {
app: app,
};
installRef.value.acceptParams(params);
if (!jumpToInstall(app.type, app.key)) {
const params = {
app: app,
};
installRef.value.acceptParams(params);
}
};

View file

@ -70,10 +70,10 @@ import { getAppByKey, getAppDetail } from '@/api/modules/app';
import MdEditor from 'md-editor-v3';
import { ref } from 'vue';
import Install from './install/index.vue';
import router from '@/routers';
import { GlobalStore } from '@/store';
import { computeSizeFromMB, jumpToPath } from '@/utils/util';
import { computeSizeFromMB } from '@/utils/util';
import { storeToRefs } from 'pinia';
import { jumpToInstall } from '@/utils/app';
const globalStore = GlobalStore();
const { isDarkTheme } = storeToRefs(globalStore);
@ -131,21 +131,12 @@ const toLink = (link: string) => {
};
const openInstall = () => {
switch (app.value.type) {
case 'php':
case 'node':
case 'java':
case 'go':
case 'python':
case 'dotnet':
jumpToPath(router, '/websites/runtimes/' + app.value.type);
break;
default:
const params = {
app: app.value,
};
installRef.value.acceptParams(params);
open.value = false;
if (!jumpToInstall(app.value.type, app.value.key)) {
const params = {
app: app.value,
};
installRef.value.acceptParams(params);
open.value = false;
}
};

View file

@ -189,6 +189,7 @@ import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { toFolder } from '@/global/business';
import { jumpToPath } from '@/utils/util';
import { jumpToInstall } from '@/utils/app';
const router = useRouter();
const globalStore = GlobalStore();
@ -209,17 +210,8 @@ const acceptParams = (): void => {
};
const goInstall = (key: string, type: string) => {
switch (type) {
case 'php':
case 'node':
case 'java':
case 'go':
case 'python':
case 'dotnet':
router.push({ path: '/websites/runtimes/' + type });
break;
default:
router.push({ name: 'AppAll', query: { install: key } });
if (!jumpToInstall(type, key)) {
router.push({ name: 'AppAll', query: { install: key } });
}
};