From a33b17d5da28bba6b2351f40f2c4450823e7321e Mon Sep 17 00:00:00 2001 From: ssongliu <73214554+ssongliu@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:08:03 +0800 Subject: [PATCH] feat: Support database terminal operations (#10407) Refs #9538 --- agent/app/api/v2/terminal.go | 95 ++++++++----------- agent/utils/terminal/local_cmd.go | 17 +++- frontend/src/components/terminal/database.vue | 56 +++++++++++ frontend/src/views/database/mysql/index.vue | 10 ++ .../src/views/database/postgresql/index.vue | 10 ++ 5 files changed, 132 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/terminal/database.vue diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 6ff5b5e6d..613726203 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "strconv" - "strings" "time" "github.com/1Panel-dev/1Panel/agent/app/dto" @@ -86,15 +85,16 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) { return } source := c.Query("source") - var containerID string var initCmd []string switch source { case "redis", "redis-cluster": - containerID, initCmd, err = loadRedisInitCmd(c, source) + initCmd, err = loadRedisInitCmd(c, source) case "ollama": - containerID, initCmd, err = loadOllamaInitCmd(c) + initCmd, err = loadOllamaInitCmd(c) case "container": - containerID, initCmd, err = loadContainerInitCmd(c) + initCmd, err = loadContainerInitCmd(c) + case "database": + initCmd, err = loadDatabaseInitCmd(c) default: if wshandleError(wsConn, fmt.Errorf("not support such source %s", source)) { return @@ -103,12 +103,10 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) { if wshandleError(wsConn, err) { return } - pidMap := loadMapFromDockerTop(containerID) slave, err := terminal.NewCommand("docker", initCmd...) if wshandleError(wsConn, err) { return } - defer killBash(containerID, strings.ReplaceAll(strings.Join(initCmd, " "), fmt.Sprintf("exec -it %s ", containerID), ""), pidMap) defer slave.Close() tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false) @@ -127,18 +125,18 @@ func (b *BaseApi) ContainerWsSSH(c *gin.Context) { _ = wsConn.WriteControl(websocket.CloseMessage, nil, dt) } -func loadRedisInitCmd(c *gin.Context, redisType string) (string, []string, error) { +func loadRedisInitCmd(c *gin.Context, redisType string) ([]string, error) { name := c.Query("name") from := c.Query("from") commands := []string{"exec", "-it"} database, err := databaseService.Get(name) if err != nil { - return "", nil, fmt.Errorf("no such database in db, err: %v", err) + return nil, fmt.Errorf("no such database in db, err: %v", err) } if from == "local" { redisInfo, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Name: name, Type: redisType}) if err != nil { - return "", nil, fmt.Errorf("no such app in db, err: %v", err) + return nil, fmt.Errorf("no such app in db, err: %v", err) } name = redisInfo.ContainerName commands = append(commands, []string{name, "redis-cli"}...) @@ -152,38 +150,61 @@ func loadRedisInitCmd(c *gin.Context, redisType string) (string, []string, error commands = append(commands, []string{"-a", database.Password, "--no-auth-warning"}...) } } - return name, commands, nil + return commands, nil } -func loadOllamaInitCmd(c *gin.Context) (string, []string, error) { +func loadOllamaInitCmd(c *gin.Context) ([]string, error) { name := c.Query("name") if cmd.CheckIllegal(name) { - return "", nil, fmt.Errorf("ollama model %s contains illegal characters", name) + return nil, fmt.Errorf("ollama model %s contains illegal characters", name) } ollamaInfo, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Name: "", Type: "ollama"}) if err != nil { - return "", nil, fmt.Errorf("no such app in db, err: %v", err) + return nil, fmt.Errorf("no such app in db, err: %v", err) } containerName := ollamaInfo.ContainerName - return containerName, []string{"exec", "-it", containerName, "ollama", "run", name}, nil + return []string{"exec", "-it", containerName, "ollama", "run", name}, nil } -func loadContainerInitCmd(c *gin.Context) (string, []string, error) { +func loadContainerInitCmd(c *gin.Context) ([]string, error) { containerID := c.Query("containerid") command := c.Query("command") user := c.Query("user") if cmd.CheckIllegal(user, containerID, command) { - return "", nil, fmt.Errorf("the command contains illegal characters. command: %s, user: %s, containerID: %s", command, user, containerID) + return nil, fmt.Errorf("the command contains illegal characters. command: %s, user: %s, containerID: %s", command, user, containerID) } if len(command) == 0 || len(containerID) == 0 { - return "", nil, fmt.Errorf("error param of command: %s or containerID: %s", command, containerID) + return nil, fmt.Errorf("error param of command: %s or containerID: %s", command, containerID) } commands := []string{"exec", "-it", containerID, command} if len(user) != 0 { commands = []string{"exec", "-it", "-u", user, containerID, command} } - return containerID, commands, nil + return commands, nil +} + +func loadDatabaseInitCmd(c *gin.Context) ([]string, error) { + database := c.Query("database") + databaseType := c.Query("databaseType") + if len(database) == 0 || len(databaseType) == 0 { + return nil, fmt.Errorf("error param of database: %s or database type: %s", database, databaseType) + } + databaseConn, err := appInstallService.LoadConnInfo(dto.OperationWithNameAndType{Type: databaseType, Name: database}) + if err != nil { + return nil, fmt.Errorf("no such database in db, err: %v", err) + } + commands := []string{"exec", "-it", databaseConn.ContainerName} + switch databaseType { + case "mysql", "mysql-cluster": + commands = append(commands, []string{"mysql", "-uroot", "-p" + databaseConn.Password}...) + case "mariadb": + commands = append(commands, []string{"mariadb", "-uroot", "-p" + databaseConn.Password}...) + case "postgresql", "postgresql-cluster": + commands = []string{"exec", "-e", fmt.Sprintf("PGPASSWORD=%s", databaseConn.Password), "-it", databaseConn.ContainerName, "psql", "-t", "-U", databaseConn.Username} + } + + return commands, nil } func wshandleError(ws *websocket.Conn, err error) bool { @@ -206,42 +227,6 @@ func wshandleError(ws *websocket.Conn, err error) bool { return false } -func loadMapFromDockerTop(containerID string) map[string]string { - pidMap := make(map[string]string) - sudo := cmd.SudoHandleCmd() - - stdout, err := cmd.RunDefaultWithStdoutBashCf("%s docker top %s -eo pid,command ", sudo, containerID) - if err != nil { - return pidMap - } - lines := strings.Split(stdout, "\n") - for _, line := range lines { - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - pidMap[parts[0]] = strings.Join(parts[1:], " ") - } - return pidMap -} - -func killBash(containerID, comm string, pidMap map[string]string) { - sudo := cmd.SudoHandleCmd() - newPidMap := loadMapFromDockerTop(containerID) - for pid, command := range newPidMap { - isOld := false - for pid2 := range pidMap { - if pid == pid2 { - isOld = true - break - } - } - if !isOld && command == comm { - _, _ = cmd.RunDefaultWithStdoutBashCf("%s kill -9 %s", sudo, pid) - } - } -} - var upGrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024 * 1024 * 10, diff --git a/agent/utils/terminal/local_cmd.go b/agent/utils/terminal/local_cmd.go index 8551ca560..aecd3e389 100644 --- a/agent/utils/terminal/local_cmd.go +++ b/agent/utils/terminal/local_cmd.go @@ -60,8 +60,23 @@ func (lcmd *LocalCommand) Write(p []byte) (n int, err error) { } func (lcmd *LocalCommand) Close() error { + if lcmd.pty != nil { + lcmd.pty.Write([]byte{3}) + time.Sleep(50 * time.Millisecond) + + lcmd.pty.Write([]byte{4}) + time.Sleep(50 * time.Millisecond) + + lcmd.pty.Write([]byte("exit\n")) + time.Sleep(50 * time.Millisecond) + } if lcmd.cmd != nil && lcmd.cmd.Process != nil { - _ = lcmd.cmd.Process.Kill() + lcmd.cmd.Process.Signal(syscall.SIGTERM) + time.Sleep(50 * time.Millisecond) + + if lcmd.cmd.ProcessState == nil || !lcmd.cmd.ProcessState.Exited() { + lcmd.cmd.Process.Kill() + } } _ = lcmd.pty.Close() return nil diff --git a/frontend/src/components/terminal/database.vue b/frontend/src/components/terminal/database.vue new file mode 100644 index 000000000..77c672473 --- /dev/null +++ b/frontend/src/components/terminal/database.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/database/mysql/index.vue b/frontend/src/views/database/mysql/index.vue index ddce9c919..55c50b6a2 100644 --- a/frontend/src/views/database/mysql/index.vue +++ b/frontend/src/views/database/mysql/index.vue @@ -46,6 +46,9 @@ {{ $t('database.remoteDB') }} + + {{ $t('menu.terminal') }} + {{ $t('database.manage') }} @@ -254,6 +257,7 @@ + @@ -263,6 +267,7 @@ import OperateDialog from '@/views/database/mysql/create/index.vue'; import DeleteDialog from '@/views/database/mysql/delete/index.vue'; import PasswordDialog from '@/views/database/mysql/password/index.vue'; import RootPasswordDialog from '@/views/database/mysql/conn/index.vue'; +import TerminalDialog from '@/components/terminal/database.vue'; import AppResources from '@/views/database/mysql/check/index.vue'; import AppStatus from '@/components/app-status/index.vue'; import Backups from '@/components/backup/index.vue'; @@ -307,6 +312,7 @@ const currentDBName = ref(); const bindRef = ref(); const checkRef = ref(); const deleteRef = ref(); +const dialogTerminalRef = ref(); const phpadminPort = ref(); const adminerPort = ref(); @@ -371,6 +377,10 @@ const goRemoteDB = async () => { routerToName('MySQL-Remote'); }; +const goTerminal = () => { + dialogTerminalRef.value.acceptParams({ databaseType: currentDB.value.type, database: currentDB.value.database }); +}; + const passwordRef = ref(); const onSetting = async () => { diff --git a/frontend/src/views/database/postgresql/index.vue b/frontend/src/views/database/postgresql/index.vue index b9ba704fb..ef04a88e7 100644 --- a/frontend/src/views/database/postgresql/index.vue +++ b/frontend/src/views/database/postgresql/index.vue @@ -44,6 +44,9 @@ {{ $t('database.remoteDB') }} + + {{ $t('menu.terminal') }} + PGAdmin4 @@ -229,6 +233,7 @@ import BindDialog from '@/views/database/postgresql/bind/index.vue'; import OperateDialog from '@/views/database/postgresql/create/index.vue'; import DeleteDialog from '@/views/database/postgresql/delete/index.vue'; import PasswordDialog from '@/views/database/postgresql/password/index.vue'; +import TerminalDialog from '@/components/terminal/database.vue'; import PrivilegesDialog from '@/views/database/postgresql/privileges/index.vue'; import RootPasswordDialog from '@/views/database/postgresql/conn/index.vue'; import AppResources from '@/views/database/postgresql/check/index.vue'; @@ -275,6 +280,7 @@ const checkRef = ref(); const deleteRef = ref(); const bindRef = ref(); const privilegesRef = ref(); +const dialogTerminalRef = ref(); const pgadminPort = ref(); const dashboardName = ref(); @@ -330,6 +336,10 @@ const goRemoteDB = async () => { routerToName('PostgreSQL-Remote'); }; +const goTerminal = () => { + dialogTerminalRef.value.acceptParams({ databaseType: currentDB.value.type, database: currentDB.value.database }); +}; + const passwordRef = ref(); const onSetting = async () => {