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
@@ -221,6 +224,7 @@
+
@@ -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 () => {