feat: Support database terminal operations (#10407)

Refs #9538
This commit is contained in:
ssongliu 2025-09-18 18:08:03 +08:00 committed by GitHub
parent 58c994a3e2
commit a33b17d5da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 132 additions and 56 deletions

View file

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

View file

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

View file

@ -0,0 +1,56 @@
<template>
<DrawerPro
v-model="open"
:header="$t('menu.terminal')"
@close="handleClose"
:resource="database"
:autoClose="!open"
size="large"
:fullScreen="true"
>
<template #content>
<Terminal style="height: calc(100vh - 100px)" ref="terminalRef"></Terminal>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { ref, nextTick } from 'vue';
import Terminal from '@/components/terminal/index.vue';
const open = ref(false);
const terminalRef = ref<InstanceType<typeof Terminal> | null>(null);
const database = ref();
const databaseType = ref();
interface DialogProps {
databaseType: string;
database: string;
}
const acceptParams = async (params: DialogProps): Promise<void> => {
database.value = params.database;
databaseType.value = params.databaseType;
open.value = false;
await initTerm();
};
const initTerm = async () => {
open.value = true;
await nextTick();
terminalRef.value!.acceptParams({
endpoint: '/api/v2/containers/exec',
args: `source=database&databaseType=${databaseType.value}&database=${database.value}`,
error: '',
initCmd: '',
});
};
function handleClose() {
terminalRef.value?.onClose();
open.value = false;
}
defineExpose({
acceptParams,
});
</script>

View file

@ -46,6 +46,9 @@
<el-button @click="goRemoteDB()" type="primary" plain>
{{ $t('database.remoteDB') }}
</el-button>
<el-button @click="goTerminal()" :disabled="currentDB?.from !== 'local'" type="primary" plain>
{{ $t('menu.terminal') }}
</el-button>
<el-dropdown>
<el-button type="primary" plain>
{{ $t('database.manage') }}
@ -254,6 +257,7 @@
<AppResources ref="checkRef"></AppResources>
<DeleteDialog ref="deleteRef" @search="search" />
<PortJumpDialog ref="dialogPortJumpRef" />
<TerminalDialog ref="dialogTerminalRef" />
</div>
</template>
@ -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 () => {

View file

@ -44,6 +44,9 @@
<el-button @click="goRemoteDB" type="primary" plain>
{{ $t('database.remoteDB') }}
</el-button>
<el-button @click="goTerminal()" :disabled="currentDB?.from !== 'local'" type="primary" plain>
{{ $t('menu.terminal') }}
</el-button>
<el-button @click="goDashboard()" type="primary" plain>PGAdmin4</el-button>
</template>
<template #rightToolBar>
@ -221,6 +224,7 @@
<DeleteDialog ref="deleteRef" @search="search" />
<PortJumpDialog ref="dialogPortJumpRef" />
<TerminalDialog ref="dialogTerminalRef" />
</div>
</template>
@ -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 () => {