mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-30 17:05:19 +08:00
feat: 添加clickhouse和mongodb的管理
This commit is contained in:
@@ -13,7 +13,7 @@ const (
|
||||
DatabaseTypePostgresql DatabaseType = "postgresql"
|
||||
DatabaseTypeMongoDB DatabaseType = "mongodb"
|
||||
DatabaseTypeClickHouse DatabaseType = "clickhouse"
|
||||
DatabaseSQLite DatabaseType = "sqlite"
|
||||
DatabaseTypeSQLite DatabaseType = "sqlite"
|
||||
DatabaseTypeRedis DatabaseType = "redis"
|
||||
)
|
||||
|
||||
|
||||
+104
-25
@@ -45,25 +45,58 @@ func (r *databaseRepo) List(page, limit uint, typ string) ([]*biz.Database, int6
|
||||
|
||||
database := make([]*biz.Database, 0)
|
||||
for _, server := range databaseServer {
|
||||
operator, err := r.getOperator(server)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if databases, err := operator.Databases(); err == nil {
|
||||
for item := range slices.Values(databases) {
|
||||
database = append(database, &biz.Database{
|
||||
Type: server.Type,
|
||||
Name: item.Name,
|
||||
Server: server.Name,
|
||||
ServerID: server.ID,
|
||||
Encoding: item.CharSet,
|
||||
Comment: item.Comment,
|
||||
})
|
||||
switch server.Type {
|
||||
case biz.DatabaseTypeMongoDB:
|
||||
mongo, err := db.NewMongoDB(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if databases, err := mongo.Databases(); err == nil {
|
||||
for item := range slices.Values(databases) {
|
||||
database = append(database, &biz.Database{
|
||||
Type: server.Type,
|
||||
Name: item.Name,
|
||||
Server: server.Name,
|
||||
ServerID: server.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
mongo.Close()
|
||||
case biz.DatabaseTypeSQLite:
|
||||
sqlite, err := db.NewSQLite(server.Host)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if tables, err := sqlite.Tables(); err == nil {
|
||||
for table := range slices.Values(tables) {
|
||||
database = append(database, &biz.Database{
|
||||
Type: server.Type,
|
||||
Name: table,
|
||||
Server: server.Name,
|
||||
ServerID: server.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
sqlite.Close()
|
||||
default:
|
||||
operator, err := r.getOperator(server)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if databases, err := operator.Databases(); err == nil {
|
||||
for item := range slices.Values(databases) {
|
||||
database = append(database, &biz.Database{
|
||||
Type: server.Type,
|
||||
Name: item.Name,
|
||||
Server: server.Name,
|
||||
ServerID: server.ID,
|
||||
Encoding: item.CharSet,
|
||||
Comment: item.Comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
operator.Close()
|
||||
}
|
||||
|
||||
operator.Close()
|
||||
}
|
||||
|
||||
if len(database) < int((page-1)*limit) {
|
||||
@@ -127,6 +160,33 @@ func (r *databaseRepo) Create(ctx context.Context, req *request.DatabaseCreate)
|
||||
if err = operator.(*db.Postgres).DatabaseComment(req.Name, req.Comment); err != nil {
|
||||
return err
|
||||
}
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
if req.CreateUser {
|
||||
if err = r.user.Create(ctx, &request.DatabaseUserCreate{
|
||||
ServerID: req.ServerID,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = operator.DatabaseCreate(req.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Username != "" {
|
||||
if err = operator.PrivilegesGrant(req.Username, req.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case biz.DatabaseTypeMongoDB:
|
||||
mongo, mongoErr := db.NewMongoDB(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if mongoErr != nil {
|
||||
return mongoErr
|
||||
}
|
||||
defer mongo.Close()
|
||||
if mongoErr = mongo.DatabaseCreate(req.Name); mongoErr != nil {
|
||||
return mongoErr
|
||||
}
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
@@ -141,14 +201,27 @@ func (r *databaseRepo) Delete(ctx context.Context, serverID uint, name string) e
|
||||
return err
|
||||
}
|
||||
|
||||
operator, err := r.getOperator(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer operator.Close()
|
||||
|
||||
if err = operator.DatabaseDrop(name); err != nil {
|
||||
return err
|
||||
switch server.Type {
|
||||
case biz.DatabaseTypeMongoDB:
|
||||
mongo, mongoErr := db.NewMongoDB(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if mongoErr != nil {
|
||||
return mongoErr
|
||||
}
|
||||
defer mongo.Close()
|
||||
if mongoErr = mongo.DatabaseDrop(name); mongoErr != nil {
|
||||
return mongoErr
|
||||
}
|
||||
case biz.DatabaseTypeSQLite:
|
||||
return errors.New(r.t.Get("sqlite does not support dropping tables from here"))
|
||||
default:
|
||||
operator, opErr := r.getOperator(server)
|
||||
if opErr != nil {
|
||||
return opErr
|
||||
}
|
||||
defer operator.Close()
|
||||
if opErr = operator.DatabaseDrop(name); opErr != nil {
|
||||
return opErr
|
||||
}
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
@@ -193,6 +266,12 @@ func (r *databaseRepo) getOperator(server *biz.DatabaseServer) (db.Operator, err
|
||||
return nil, err
|
||||
}
|
||||
return postgres, nil
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
clickhouse, err := db.NewClickHouse(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clickhouse, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", server.Type)
|
||||
}
|
||||
|
||||
@@ -191,6 +191,25 @@ func (r *databaseServerRepo) Sync(id uint) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
allUsers, err := operator.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for user := range slices.Values(allUsers) {
|
||||
if !slices.ContainsFunc(users, func(a *biz.DatabaseUser) bool {
|
||||
return a.Username == user.User
|
||||
}) && !slices.Contains([]string{"default"}, user.User) {
|
||||
newUser := &biz.DatabaseUser{
|
||||
ServerID: id,
|
||||
Username: user.User,
|
||||
Remark: r.t.Get("sync from server %s", server.Name),
|
||||
}
|
||||
if err = r.db.Create(newUser).Error; err != nil {
|
||||
r.log.Warn("sync clickhouse database user failed", slog.String("type", biz.OperationTypeDatabaseServer), slog.Uint64("operator_id", 0), slog.Any("err", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -199,7 +218,7 @@ func (r *databaseServerRepo) Sync(id uint) error {
|
||||
// checkServer 检查服务器连接
|
||||
func (r *databaseServerRepo) checkServer(server *biz.DatabaseServer) bool {
|
||||
switch server.Type {
|
||||
case biz.DatabaseTypeMysql, biz.DatabaseTypePostgresql:
|
||||
case biz.DatabaseTypeMysql, biz.DatabaseTypePostgresql, biz.DatabaseTypeClickHouse:
|
||||
operator, err := r.getOperator(server)
|
||||
if err == nil {
|
||||
operator.Close()
|
||||
@@ -213,6 +232,20 @@ func (r *databaseServerRepo) checkServer(server *biz.DatabaseServer) bool {
|
||||
server.Status = biz.DatabaseServerStatusValid
|
||||
return true
|
||||
}
|
||||
case biz.DatabaseTypeMongoDB:
|
||||
mongo, err := db.NewMongoDB(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if err == nil {
|
||||
mongo.Close()
|
||||
server.Status = biz.DatabaseServerStatusValid
|
||||
return true
|
||||
}
|
||||
case biz.DatabaseTypeSQLite:
|
||||
sqlite, err := db.NewSQLite(server.Host)
|
||||
if err == nil {
|
||||
sqlite.Close()
|
||||
server.Status = biz.DatabaseServerStatusValid
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
server.Status = biz.DatabaseServerStatusInvalid
|
||||
@@ -233,6 +266,12 @@ func (r *databaseServerRepo) getOperator(server *biz.DatabaseServer) (db.Operato
|
||||
return nil, err
|
||||
}
|
||||
return postgres, nil
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
clickhouse, err := db.NewClickHouse(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clickhouse, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", server.Type)
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ func (r *databaseUserRepo) DeleteByNames(serverID uint, names []string) error {
|
||||
}
|
||||
_ = operator.UserDrop(name, host)
|
||||
}
|
||||
case biz.DatabaseTypePostgresql:
|
||||
case biz.DatabaseTypePostgresql, biz.DatabaseTypeClickHouse:
|
||||
for name := range slices.Values(names) {
|
||||
_ = operator.UserDrop(name)
|
||||
}
|
||||
@@ -257,6 +257,15 @@ func (r *databaseUserRepo) fillUser(user *biz.DatabaseUser) {
|
||||
} else {
|
||||
user.Status = biz.DatabaseUserStatusInvalid
|
||||
}
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
privileges, _ := operator.UserPrivileges(user.Username)
|
||||
user.Privileges = privileges
|
||||
if ch2, err := db.NewClickHouse(user.Username, user.Password, fmt.Sprintf("%s:%d", server.Host, server.Port)); err == nil {
|
||||
ch2.Close()
|
||||
user.Status = biz.DatabaseUserStatusValid
|
||||
} else {
|
||||
user.Status = biz.DatabaseUserStatusInvalid
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user.Status = biz.DatabaseUserStatusInvalid
|
||||
@@ -282,6 +291,12 @@ func (r *databaseUserRepo) getOperator(server *biz.DatabaseServer) (db.Operator,
|
||||
return nil, err
|
||||
}
|
||||
return postgres, nil
|
||||
case biz.DatabaseTypeClickHouse:
|
||||
clickhouse, err := db.NewClickHouse(server.Username, server.Password, fmt.Sprintf("%s:%d", server.Host, server.Port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clickhouse, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", server.Type)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package request
|
||||
|
||||
type DatabaseServerCreate struct {
|
||||
Name string `form:"name" json:"name" validate:"required|notExists:database_servers,name|regex:^[a-zA-Z0-9_-]+$"`
|
||||
Type string `form:"type" json:"type" validate:"required|in:mysql,postgresql,redis"`
|
||||
Type string `form:"type" json:"type" validate:"required|in:mysql,postgresql,redis,clickhouse,mongodb,sqlite"`
|
||||
Host string `form:"host" json:"host" validate:"required"`
|
||||
Port uint `form:"port" json:"port" validate:"required|min:1|max:65535"`
|
||||
Username string `form:"username" json:"username"`
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// ClickHouse 通过 HTTP API 操作 ClickHouse
|
||||
type ClickHouse struct {
|
||||
client *resty.Client
|
||||
address string
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewClickHouse 创建 ClickHouse 连接(HTTP API)
|
||||
func NewClickHouse(username, password, address string) (*ClickHouse, error) {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(fmt.Sprintf("http://%s", address))
|
||||
client.SetTimeout(10 * 1000 * 1000 * 1000) // 10s
|
||||
|
||||
ch := &ClickHouse{
|
||||
client: client,
|
||||
address: address,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := ch.Ping(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("connect to clickhouse failed: %w", err)
|
||||
}
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Close() {
|
||||
_ = r.client.Close()
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Ping() error {
|
||||
_, err := r.exec("SELECT 1")
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return nil, fmt.Errorf("clickhouse HTTP API does not support sql.Rows")
|
||||
}
|
||||
|
||||
func (r *ClickHouse) QueryRow(query string, args ...any) *sql.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Exec(query string, args ...any) (sql.Result, error) {
|
||||
_, err := r.exec(query)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Prepare(query string) (*sql.Stmt, error) {
|
||||
return nil, fmt.Errorf("clickhouse HTTP API does not support Prepare")
|
||||
}
|
||||
|
||||
func (r *ClickHouse) DatabaseCreate(name string) error {
|
||||
_, err := r.exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", name))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) DatabaseDrop(name string) error {
|
||||
_, err := r.exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", name))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) DatabaseExists(name string) (bool, error) {
|
||||
result, err := r.exec(fmt.Sprintf("SELECT count() FROM system.databases WHERE name = '%s'", name))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(result) != "0", nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) DatabaseSize(name string) (int64, error) {
|
||||
result, err := r.exec(fmt.Sprintf("SELECT COALESCE(sum(bytes_on_disk), 0) FROM system.parts WHERE database = '%s'", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var size int64
|
||||
if _, err = fmt.Sscanf(strings.TrimSpace(result), "%d", &size); err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) UserCreate(user, password string, host ...string) error {
|
||||
_, err := r.exec(fmt.Sprintf("CREATE USER IF NOT EXISTS `%s` IDENTIFIED BY '%s'", user, password))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) UserDrop(user string, host ...string) error {
|
||||
_, err := r.exec(fmt.Sprintf("DROP USER IF EXISTS `%s`", user))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) UserPassword(user, password string, host ...string) error {
|
||||
_, err := r.exec(fmt.Sprintf("ALTER USER `%s` IDENTIFIED BY '%s'", user, password))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) UserPrivileges(user string, host ...string) ([]string, error) {
|
||||
result, err := r.exec(fmt.Sprintf("SHOW GRANTS FOR `%s`", user))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var databases []string
|
||||
for line := range strings.SplitSeq(result, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// 解析 GRANT ALL ON database.* TO user 格式
|
||||
if idx := strings.Index(line, " ON "); idx != -1 {
|
||||
rest := line[idx+4:]
|
||||
if dotIdx := strings.Index(rest, "."); dotIdx != -1 {
|
||||
dbName := strings.Trim(rest[:dotIdx], "`")
|
||||
if dbName != "" && dbName != "*" {
|
||||
databases = append(databases, dbName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) PrivilegesGrant(user, database string, host ...string) error {
|
||||
_, err := r.exec(fmt.Sprintf("GRANT ALL ON `%s`.* TO `%s`", database, user))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) PrivilegesRevoke(user, database string, host ...string) error {
|
||||
_, err := r.exec(fmt.Sprintf("REVOKE ALL ON `%s`.* FROM `%s`", database, user))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Users() ([]User, error) {
|
||||
result, err := r.exec("SELECT name FROM system.users FORMAT TabSeparated")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var users []User
|
||||
for line := range strings.SplitSeq(result, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
users = append(users, User{User: line})
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouse) Databases() ([]Database, error) {
|
||||
result, err := r.exec("SELECT name FROM system.databases WHERE name NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema') FORMAT TabSeparated")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var databases []Database
|
||||
for line := range strings.SplitSeq(result, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, Database{Name: line})
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
// exec 通过 HTTP API 执行 SQL
|
||||
func (r *ClickHouse) exec(query string) (string, error) {
|
||||
resp, err := r.client.R().
|
||||
SetQueryParam("query", query).
|
||||
SetQueryParam("user", r.username).
|
||||
SetQueryParam("password", url.QueryEscape(r.password)).
|
||||
Get("/")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clickhouse query failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return "", fmt.Errorf("clickhouse query error: %s", strings.TrimSpace(resp.String()))
|
||||
}
|
||||
return resp.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
)
|
||||
|
||||
// MongoDB 通过 mongosh CLI 操作 MongoDB
|
||||
type MongoDB struct {
|
||||
username string
|
||||
password string
|
||||
address string // host:port
|
||||
}
|
||||
|
||||
// NewMongoDB 创建 MongoDB 连接
|
||||
func NewMongoDB(username, password, address string) (*MongoDB, error) {
|
||||
m := &MongoDB{
|
||||
username: username,
|
||||
password: password,
|
||||
address: address,
|
||||
}
|
||||
|
||||
if err := m.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connect to mongodb failed: %w", err)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *MongoDB) Close() {}
|
||||
|
||||
func (r *MongoDB) Ping() error {
|
||||
_, err := r.mongosh(`db.runCommand({ping:1})`)
|
||||
return err
|
||||
}
|
||||
|
||||
// DatabaseCreate 创建数据库(MongoDB 通过创建集合来显式创建数据库)
|
||||
func (r *MongoDB) DatabaseCreate(name string) error {
|
||||
_, err := r.mongosh(fmt.Sprintf(`db.getSiblingDB('%s').createCollection('_init')`, name))
|
||||
return err
|
||||
}
|
||||
|
||||
// DatabaseDrop 删除数据库
|
||||
func (r *MongoDB) DatabaseDrop(name string) error {
|
||||
_, err := r.mongosh(fmt.Sprintf(`db.getSiblingDB('%s').dropDatabase()`, name))
|
||||
return err
|
||||
}
|
||||
|
||||
// Databases 获取数据库列表
|
||||
func (r *MongoDB) Databases() ([]MongoDatabase, error) {
|
||||
raw, err := r.mongosh(`JSON.stringify(db.adminCommand({listDatabases:1,nameOnly:false}))`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Databases []struct {
|
||||
Name string `json:"name"`
|
||||
SizeOnDisk int64 `json:"sizeOnDisk"`
|
||||
} `json:"databases"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse databases: %w", err)
|
||||
}
|
||||
|
||||
var databases []MongoDatabase
|
||||
for _, db := range result.Databases {
|
||||
if db.Name == "admin" || db.Name == "config" || db.Name == "local" {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, MongoDatabase{
|
||||
Name: db.Name,
|
||||
SizeOnDisk: db.SizeOnDisk,
|
||||
})
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
// UserCreate 创建用户
|
||||
func (r *MongoDB) UserCreate(user, password, database string) error {
|
||||
_, err := r.mongosh(fmt.Sprintf(`db.getSiblingDB('%s').createUser({user:'%s',pwd:'%s',roles:[{role:'readWrite',db:'%s'}]})`, database, user, password, database))
|
||||
return err
|
||||
}
|
||||
|
||||
// UserDrop 删除用户
|
||||
func (r *MongoDB) UserDrop(user, database string) error {
|
||||
_, err := r.mongosh(fmt.Sprintf(`db.getSiblingDB('%s').dropUser('%s')`, database, user))
|
||||
return err
|
||||
}
|
||||
|
||||
// UserPassword 修改用户密码
|
||||
func (r *MongoDB) UserPassword(user, password string) error {
|
||||
_, err := r.mongosh(fmt.Sprintf(`db.getSiblingDB('admin').changeUserPassword('%s','%s')`, user, password))
|
||||
return err
|
||||
}
|
||||
|
||||
// Users 获取用户列表
|
||||
func (r *MongoDB) Users() ([]MongoUser, error) {
|
||||
raw, err := r.mongosh(`JSON.stringify(db.getSiblingDB('admin').system.users.find({},{user:1,db:1,roles:1}).toArray())`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []struct {
|
||||
User string `json:"user"`
|
||||
DB string `json:"db"`
|
||||
Roles []struct {
|
||||
Role string `json:"role"`
|
||||
DB string `json:"db"`
|
||||
} `json:"roles"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse users: %w", err)
|
||||
}
|
||||
|
||||
var users []MongoUser
|
||||
for _, u := range result {
|
||||
var roles []string
|
||||
for _, role := range u.Roles {
|
||||
roles = append(roles, fmt.Sprintf("%s@%s", role.Role, role.DB))
|
||||
}
|
||||
users = append(users, MongoUser{
|
||||
User: u.User,
|
||||
DB: u.DB,
|
||||
Roles: roles,
|
||||
})
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// mongosh 执行 mongosh 命令
|
||||
func (r *MongoDB) mongosh(eval string) (string, error) {
|
||||
cmd := fmt.Sprintf(`mongosh --quiet --eval "%s" mongodb://%s:%s@%s/admin 2>/dev/null`,
|
||||
strings.ReplaceAll(eval, `"`, `\"`),
|
||||
r.username, r.password, r.address,
|
||||
)
|
||||
raw, err := shell.Execf(cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mongosh error: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(raw), nil
|
||||
}
|
||||
|
||||
// MongoDatabase MongoDB 数据库信息
|
||||
type MongoDatabase struct {
|
||||
Name string `json:"name"`
|
||||
SizeOnDisk int64 `json:"size_on_disk"`
|
||||
}
|
||||
|
||||
// MongoUser MongoDB 用户信息
|
||||
type MongoUser struct {
|
||||
User string `json:"user"`
|
||||
DB string `json:"db"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// SQLite 通过 database/sql 操作 SQLite 文件
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// NewSQLite 打开 SQLite 数据库文件
|
||||
func NewSQLite(path string) (*SQLite, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite failed: %w", err)
|
||||
}
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connect to sqlite failed: %w", err)
|
||||
}
|
||||
return &SQLite{db: db, path: path}, nil
|
||||
}
|
||||
|
||||
func (r *SQLite) Close() {
|
||||
_ = r.db.Close()
|
||||
}
|
||||
|
||||
func (r *SQLite) Ping() error {
|
||||
return r.db.Ping()
|
||||
}
|
||||
|
||||
// Tables 获取所有表
|
||||
func (r *SQLite) Tables() ([]string, error) {
|
||||
rows, err := r.db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err = rows.Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
return tables, rows.Err()
|
||||
}
|
||||
|
||||
// SQLiteColumn 表列信息
|
||||
type SQLiteColumn struct {
|
||||
CID int `json:"cid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
NotNull bool `json:"not_null"`
|
||||
Default string `json:"default"`
|
||||
PrimaryKey bool `json:"primary_key"`
|
||||
}
|
||||
|
||||
// TableInfo 获取表结构
|
||||
func (r *SQLite) TableInfo(name string) ([]SQLiteColumn, error) {
|
||||
rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info('%s')", name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var columns []SQLiteColumn
|
||||
for rows.Next() {
|
||||
var col SQLiteColumn
|
||||
var dflt sql.NullString
|
||||
if err = rows.Scan(&col.CID, &col.Name, &col.Type, &col.NotNull, &dflt, &col.PrimaryKey); err != nil {
|
||||
continue
|
||||
}
|
||||
col.Default = dflt.String
|
||||
columns = append(columns, col)
|
||||
}
|
||||
return columns, rows.Err()
|
||||
}
|
||||
@@ -12,6 +12,9 @@ const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const defaultPort = (type: string) => {
|
||||
if (type === 'postgresql') return 5432
|
||||
if (type === 'redis') return 6379
|
||||
if (type === 'clickhouse') return 8123
|
||||
if (type === 'mongodb') return 27017
|
||||
if (type === 'sqlite') return 0
|
||||
return 3306
|
||||
}
|
||||
|
||||
@@ -28,6 +31,9 @@ const createModel = ref({
|
||||
const typeOptions = [
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'PostgreSQL', value: 'postgresql' },
|
||||
{ label: 'ClickHouse', value: 'clickhouse' },
|
||||
{ label: 'MongoDB', value: 'mongodb' },
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'Redis', value: 'redis' }
|
||||
]
|
||||
|
||||
@@ -35,13 +41,7 @@ const typeOptions = [
|
||||
watch(
|
||||
() => createModel.value.type,
|
||||
(val) => {
|
||||
if (val === 'postgresql') {
|
||||
createModel.value.port = 5432
|
||||
} else if (val === 'redis') {
|
||||
createModel.value.port = 6379
|
||||
} else {
|
||||
createModel.value.port = 3306
|
||||
}
|
||||
createModel.value.port = defaultPort(val)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -100,46 +100,58 @@ const handleCreate = () => {
|
||||
:placeholder="$gettext('Enter database server name')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="15">
|
||||
<n-form-item path="host" :label="$gettext('Host')">
|
||||
<n-input
|
||||
v-model:value="createModel.host"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server host')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="2"></n-col>
|
||||
<n-col :span="7">
|
||||
<n-form-item path="port" :label="$gettext('Port')">
|
||||
<n-input-number
|
||||
w-full
|
||||
v-model:value="createModel.port"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server port')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
<n-form-item v-if="createModel.type !== 'redis'" path="username" :label="$gettext('Username')">
|
||||
<n-input
|
||||
v-model:value="createModel.username"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server username')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="password" :label="$gettext('Password')">
|
||||
<n-input
|
||||
v-model:value="createModel.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server password')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<template v-if="createModel.type === 'sqlite'">
|
||||
<n-form-item path="host" :label="$gettext('File Path')">
|
||||
<n-input
|
||||
v-model:value="createModel.host"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter SQLite database file path, e.g. /data/app.db')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="15">
|
||||
<n-form-item path="host" :label="$gettext('Host')">
|
||||
<n-input
|
||||
v-model:value="createModel.host"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server host')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
<n-col :span="2"></n-col>
|
||||
<n-col :span="7">
|
||||
<n-form-item path="port" :label="$gettext('Port')">
|
||||
<n-input-number
|
||||
w-full
|
||||
v-model:value="createModel.port"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server port')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-col>
|
||||
</n-row>
|
||||
<n-form-item v-if="!['redis', 'sqlite'].includes(createModel.type)" path="username" :label="$gettext('Username')">
|
||||
<n-input
|
||||
v-model:value="createModel.username"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server username')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="createModel.type !== 'sqlite'" path="password" :label="$gettext('Password')">
|
||||
<n-input
|
||||
v-model:value="createModel.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('Enter database server password')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
<n-form-item path="remark" :label="$gettext('Comment')">
|
||||
<n-input
|
||||
v-model:value="createModel.remark"
|
||||
|
||||
@@ -27,6 +27,9 @@ const createServerModalShow = ref(false)
|
||||
<n-tabs v-model:value="currentTab" animated>
|
||||
<n-tab name="mysql" tab="MySQL" />
|
||||
<n-tab name="postgresql" tab="PostgreSQL" />
|
||||
<n-tab name="clickhouse" tab="ClickHouse" />
|
||||
<n-tab name="mongodb" tab="MongoDB" />
|
||||
<n-tab name="sqlite" tab="SQLite" />
|
||||
<n-tab name="redis" tab="Redis" />
|
||||
<n-tab name="user" :tab="$gettext('User')" />
|
||||
<n-tab name="server" :tab="$gettext('Server')" />
|
||||
@@ -35,7 +38,7 @@ const createServerModalShow = ref(false)
|
||||
<n-flex vertical>
|
||||
<n-flex v-if="currentTab !== 'redis'">
|
||||
<n-button
|
||||
v-if="currentTab === 'mysql' || currentTab === 'postgresql'"
|
||||
v-if="['mysql', 'postgresql', 'clickhouse', 'mongodb'].includes(currentTab)"
|
||||
type="primary"
|
||||
@click="createDatabaseModalShow = true"
|
||||
>
|
||||
@@ -54,6 +57,9 @@ const createServerModalShow = ref(false)
|
||||
</n-flex>
|
||||
<database-list v-if="currentTab === 'mysql'" type="mysql" />
|
||||
<database-list v-if="currentTab === 'postgresql'" type="postgresql" />
|
||||
<database-list v-if="currentTab === 'clickhouse'" type="clickhouse" />
|
||||
<database-list v-if="currentTab === 'mongodb'" type="mongodb" />
|
||||
<database-list v-if="currentTab === 'sqlite'" type="sqlite" />
|
||||
<redis-data-view v-if="currentTab === 'redis'" />
|
||||
<user-list v-if="currentTab === 'user'" />
|
||||
<server-list v-if="currentTab === 'server'" />
|
||||
|
||||
@@ -40,6 +40,15 @@ const openTerminal = (row: any) => {
|
||||
} else {
|
||||
terminalCommand.value = `redis-cli -h '${row.host}' -p '${row.port}'`
|
||||
}
|
||||
} else if (row.type === 'clickhouse') {
|
||||
terminalTitle.value = `ClickHouse - ${row.name}`
|
||||
terminalCommand.value = `clickhouse-client --host '${row.host}' --port 9000 --user '${row.username}' --password '${row.password}'`
|
||||
} else if (row.type === 'mongodb') {
|
||||
terminalTitle.value = `MongoDB - ${row.name}`
|
||||
terminalCommand.value = `mongosh mongodb://${row.username}:${row.password}@${row.host}:${row.port}/admin`
|
||||
} else if (row.type === 'sqlite') {
|
||||
terminalTitle.value = `SQLite - ${row.name}`
|
||||
terminalCommand.value = `sqlite3 '${row.host}'`
|
||||
} else {
|
||||
window.$message.error($gettext('Unsupported database type'))
|
||||
return
|
||||
@@ -161,7 +170,7 @@ const columns: any = [
|
||||
default: () => $gettext('Terminal')
|
||||
}
|
||||
),
|
||||
row.type !== 'redis'
|
||||
!['redis', 'mongodb', 'sqlite'].includes(row.type)
|
||||
? h(
|
||||
NPopconfirm,
|
||||
{
|
||||
@@ -214,7 +223,7 @@ const columns: any = [
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
// 防手贱
|
||||
if (['local_mysql', 'local_postgresql', 'local_redis'].includes(row.name)) {
|
||||
if (['local_mysql', 'local_postgresql', 'local_redis', 'local_clickhouse', 'local_mongodb'].includes(row.name)) {
|
||||
window.$message.error(
|
||||
$gettext(
|
||||
'Built-in servers cannot be deleted. If you need to delete them, please uninstall the corresponding app'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableSortState, DropdownOption } from 'naive-ui'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { NButton, NDropdown, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import process, { type ProcessListParams } from '@/api/panel/process'
|
||||
@@ -54,24 +54,21 @@ const statusOptions = [
|
||||
{ label: $gettext('Locked'), value: 'L' }
|
||||
]
|
||||
|
||||
// 右键菜单选项
|
||||
const dropdownOptions = computed<DropdownOption[]>(() => {
|
||||
if (!selectedRow.value) return []
|
||||
return [
|
||||
{ label: $gettext('View Details'), key: 'detail' },
|
||||
{ type: 'divider', key: 'd1' },
|
||||
{ label: $gettext('Terminate (SIGTERM)'), key: 'sigterm' },
|
||||
{ label: $gettext('Kill (SIGKILL)'), key: 'sigkill' },
|
||||
{ type: 'divider', key: 'd2' },
|
||||
{ label: $gettext('Stop (SIGSTOP)'), key: 'sigstop' },
|
||||
{ label: $gettext('Continue (SIGCONT)'), key: 'sigcont' },
|
||||
{ type: 'divider', key: 'd3' },
|
||||
{ label: $gettext('Interrupt (SIGINT)'), key: 'sigint' },
|
||||
{ label: $gettext('Hang Up (SIGHUP)'), key: 'sighup' },
|
||||
{ label: $gettext('User Signal 1 (SIGUSR1)'), key: 'sigusr1' },
|
||||
{ label: $gettext('User Signal 2 (SIGUSR2)'), key: 'sigusr2' }
|
||||
]
|
||||
})
|
||||
// 操作菜单选项
|
||||
const actionOptions: DropdownOption[] = [
|
||||
{ label: $gettext('View Details'), key: 'detail' },
|
||||
{ type: 'divider', key: 'd1' },
|
||||
{ label: $gettext('Terminate (SIGTERM)'), key: 'sigterm' },
|
||||
{ label: $gettext('Kill (SIGKILL)'), key: 'sigkill' },
|
||||
{ type: 'divider', key: 'd2' },
|
||||
{ label: $gettext('Stop (SIGSTOP)'), key: 'sigstop' },
|
||||
{ label: $gettext('Continue (SIGCONT)'), key: 'sigcont' },
|
||||
{ type: 'divider', key: 'd3' },
|
||||
{ label: $gettext('Interrupt (SIGINT)'), key: 'sigint' },
|
||||
{ label: $gettext('Hang Up (SIGHUP)'), key: 'sighup' },
|
||||
{ label: $gettext('User Signal 1 (SIGUSR1)'), key: 'sigusr1' },
|
||||
{ label: $gettext('User Signal 2 (SIGUSR2)'), key: 'sigusr2' }
|
||||
]
|
||||
|
||||
// 渲染状态标签
|
||||
const renderStatus = (status: string) => {
|
||||
@@ -167,10 +164,33 @@ const columns = computed<any[]>(() => [
|
||||
width: 240,
|
||||
sortOrder: sortKeyMapOrder.value.start_time || false,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NDropdown,
|
||||
{
|
||||
options: actionOptions,
|
||||
trigger: 'click',
|
||||
onSelect: (key: string) => {
|
||||
selectedRow.value = row
|
||||
handleDropdownSelect(key)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(NButton, { size: 'small', ghost: true }, { default: () => $gettext('Actions') })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// 行属性 - 右键菜单
|
||||
// 行属性 - 右键菜单 & 双击查看详情
|
||||
const rowProps = (row: any) => {
|
||||
return {
|
||||
onContextmenu: (e: MouseEvent) => {
|
||||
@@ -182,6 +202,9 @@ const rowProps = (row: any) => {
|
||||
dropdownX.value = e.clientX
|
||||
dropdownY.value = e.clientY
|
||||
})
|
||||
},
|
||||
onDblclick: () => {
|
||||
handleShowDetail(row.pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,7 +357,7 @@ const { loading, data, page, total, pageSize, pageCount, reload } = usePaginatio
|
||||
striped
|
||||
remote
|
||||
virtual-scroll
|
||||
:scroll-x="1400"
|
||||
:scroll-x="1480"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -361,7 +384,7 @@ const { loading, data, page, total, pageSize, pageCount, reload } = usePaginatio
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:options="actionOptions"
|
||||
:show="showDropdown"
|
||||
:on-clickoutside="onCloseDropdown"
|
||||
@select="handleDropdownSelect"
|
||||
|
||||
Reference in New Issue
Block a user