feat: 数据库面板新增PostgreSQL管理,支持远程/本地数据库,支持与应用关联等 #758 #1978 (#3447)

* feat: 数据库面板新增 PostgreSQL 管理,支持远程/本地数据库,支持与应用关联等 #758 #1978
This commit is contained in:
Node 2023-12-28 16:29:18 +08:00 committed by GitHub
parent 14e2ced3da
commit 46e7431c4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 4195 additions and 112 deletions

View File

@ -300,6 +300,11 @@ func (b *BaseApi) Backup(c *gin.Context) {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case constant.AppPostgresql:
if err := backupService.PostgresqlBackup(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case "website":
if err := backupService.WebsiteBackup(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
@ -343,6 +348,11 @@ func (b *BaseApi) Recover(c *gin.Context) {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case constant.AppPostgresql:
if err := backupService.PostgresqlRecover(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case "website":
if err := backupService.WebsiteRecover(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
@ -383,6 +393,11 @@ func (b *BaseApi) RecoverByUpload(c *gin.Context) {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case constant.AppPostgresql:
if err := backupService.PostgresqlRecoverByUpload(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
case "app":
if err := backupService.AppRecover(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)

View File

@ -206,3 +206,31 @@ func (b *BaseApi) UpdateDatabase(c *gin.Context) {
}
helper.SuccessWithData(c, nil)
}
// @Tags Database
// @Summary Load Database file
// @Description 获取数据库文件
// @Accept json
// @Param request body dto.OperationWithNameAndType true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/load/file [post]
func (b *BaseApi) LoadDatabaseFile(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
var content string
var err error
switch req.Name {
case constant.AppPostgresql:
content, err = postgresqlService.LoadDatabaseFile(req)
default:
content, err = mysqlService.LoadDatabaseFile(req)
}
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, content)
}

View File

@ -327,28 +327,6 @@ func (b *BaseApi) LoadBaseinfo(c *gin.Context) {
helper.SuccessWithData(c, data)
}
// @Tags Database
// @Summary Load Database file
// @Description 获取数据库文件
// @Accept json
// @Param request body dto.OperationWithNameAndType true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/load/file [post]
func (b *BaseApi) LoadDatabaseFile(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
content, err := mysqlService.LoadDatabaseFile(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, content)
}
// @Tags Database Mysql
// @Summary Load mysql remote access
// @Description 获取 mysql 远程访问权限
@ -362,7 +340,10 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if req.Type == constant.AppPostgresql {
helper.SuccessWithData(c, true)
return
}
isRemote, err := mysqlService.LoadRemoteAccess(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)

View File

@ -0,0 +1,321 @@
package v1
import (
"context"
"encoding/base64"
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/gin-gonic/gin"
)
// @Tags Database Postgresql
// @Summary Create postgresql database
// @Description 创建 postgresql 数据库
// @Accept json
// @Param request body dto.PostgresqlDBCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 postgresql 数据库 [name]","formatEN":"create postgresql database [name]"}
func (b *BaseApi) CreatePostgresql(c *gin.Context) {
var req dto.PostgresqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
req.Password = string(password)
}
if _, err := postgresqlService.Create(context.Background(), req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Update postgresql database description
// @Description 更新 postgresql 数据库库描述信息
// @Accept json
// @Param request body dto.UpdateDescription true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/description [post]
// @x-panel-log {"bodyKeys":["id","description"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_postgresqls","output_column":"name","output_value":"name"}],"formatZH":"postgresql 数据库 [name] 描述信息修改 [description]","formatEN":"The description of the postgresql database [name] is modified => [description]"}
func (b *BaseApi) UpdatePostgresqlDescription(c *gin.Context) {
var req dto.UpdateDescription
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := postgresqlService.UpdateDescription(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Change postgresql password
// @Description 修改 postgresql 密码
// @Accept json
// @Param request body dto.ChangeDBInfo true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/password [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_postgresqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 密码","formatEN":"Update database [name] password"}
func (b *BaseApi) ChangePostgresqlPassword(c *gin.Context) {
var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Value) != 0 {
value, err := base64.StdEncoding.DecodeString(req.Value)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
req.Value = string(value)
}
if err := postgresqlService.ChangePassword(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Change postgresql access
// @Description 修改 postgresql 访问权限
// @Accept json
// @Param request body dto.ChangeDBInfo true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/change/access [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_postgresqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 访问权限","formatEN":"Update database [name] access"}
func (b *BaseApi) ChangePostgresqlAccess(c *gin.Context) {
var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := postgresqlService.ChangeAccess(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Update postgresql variables
// @Description postgresql 性能调优
// @Accept json
// @Param request body dto.PostgresqlVariablesUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/variables/update [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整 postgresql 数据库性能参数","formatEN":"adjust postgresql database performance parameters"}
func (b *BaseApi) UpdatePostgresqlVariables(c *gin.Context) {
//var req dto.PostgresqlVariablesUpdate
//if err := helper.CheckBindAndValidate(&req, c); err != nil {
// return
//}
//
//if err := postgresqlService.UpdateVariables(req); err != nil {
// helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
// return
//}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Update postgresql conf by upload file
// @Description 上传替换 postgresql 配置文件
// @Accept json
// @Param request body dto.PostgresqlConfUpdateByFile true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/conf [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 postgresql 数据库配置信息","formatEN":"update the postgresql database configuration information"}
func (b *BaseApi) UpdatePostgresqlConfByFile(c *gin.Context) {
var req dto.PostgresqlConfUpdateByFile
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := postgresqlService.UpdateConfByFile(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Page postgresql databases
// @Description 获取 postgresql 数据库列表分页
// @Accept json
// @Param request body dto.PostgresqlDBSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Router /databases/pg/search [post]
func (b *BaseApi) SearchPostgresql(c *gin.Context) {
var req dto.PostgresqlDBSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := postgresqlService.SearchWithPage(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
}
// @Tags Database Postgresql
// @Summary List postgresql database names
// @Description 获取 postgresql 数据库列表
// @Accept json
// @Param request body dto.PageInfo true "request"
// @Success 200 {array} dto.PostgresqlOption
// @Security ApiKeyAuth
// @Router /databases/pg/options [get]
func (b *BaseApi) ListPostgresqlDBName(c *gin.Context) {
list, err := postgresqlService.ListDBOption()
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags Database Postgresql
// @Summary Load postgresql database from remote
// @Description 从服务器获取
// @Accept json
// @Param request body dto.PostgresqlLoadDB true "request"
// @Security ApiKeyAuth
// @Router /databases/pg/load [post]
func (b *BaseApi) LoadPostgresqlDBFromRemote(c *gin.Context) {
//var req dto.PostgresqlLoadDB
//if err := helper.CheckBindAndValidate(&req, c); err != nil {
// return
//}
//
//if err := postgresqlService.LoadFromRemote(req); err != nil {
// helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
// return
//}
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Check before delete postgresql database
// @Description Postgresql 数据库删除前检查
// @Accept json
// @Param request body dto.PostgresqlDBDeleteCheck true "request"
// @Success 200 {array} string
// @Security ApiKeyAuth
// @Router /databases/pg/del/check [post]
func (b *BaseApi) DeleteCheckPostgresql(c *gin.Context) {
var req dto.PostgresqlDBDeleteCheck
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
apps, err := postgresqlService.DeleteCheck(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, apps)
}
// @Tags Database Postgresql
// @Summary Delete postgresql database
// @Description 删除 postgresql 数据库
// @Accept json
// @Param request body dto.PostgresqlDBDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Router /databases/pg/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_postgresqls","output_column":"name","output_value":"name"}],"formatZH":"删除 postgresql 数据库 [name]","formatEN":"delete postgresql database [name]"}
func (b *BaseApi) DeletePostgresql(c *gin.Context) {
var req dto.PostgresqlDBDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
tx, ctx := helper.GetTxAndContext()
if err := postgresqlService.Delete(ctx, req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
tx.Rollback()
return
}
tx.Commit()
helper.SuccessWithData(c, nil)
}
// @Tags Database Postgresql
// @Summary Load postgresql base info
// @Description 获取 postgresql 基础信息
// @Accept json
// @Param request body dto.OperationWithNameAndType true "request"
// @Success 200 {object} dto.DBBaseInfo
// @Security ApiKeyAuth
// @Router /databases/pg/baseinfo [post]
func (b *BaseApi) LoadPostgresqlBaseinfo(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := postgresqlService.LoadBaseInfo(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Postgresql
// @Summary Load postgresql status info
// @Description 获取 postgresql 状态信息
// @Accept json
// @Param request body dto.OperationWithNameAndType true "request"
// @Success 200 {object} dto.PostgresqlStatus
// @Security ApiKeyAuth
// @Router /databases/pg/status [post]
func (b *BaseApi) LoadPostgresqlStatus(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := postgresqlService.LoadStatus(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, data)
}

View File

@ -22,6 +22,7 @@ var (
dockerService = service.NewIDockerService()
mysqlService = service.NewIMysqlService()
postgresqlService = service.NewIPostgresqlService()
databaseService = service.NewIDatabaseService()
redisService = service.NewIRedisService()

View File

@ -26,13 +26,13 @@ type BackupSearchFile struct {
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql"`
Name string `json:"name"`
DetailName string `json:"detailName"`
}
type CommonRecover struct {
Source string `json:"source" validate:"required,oneof=OSS S3 SFTP MINIO LOCAL COS KODO OneDrive WebDAV"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql"`
Name string `json:"name"`
DetailName string `json:"detailName"`
File string `json:"file"`

View File

@ -146,11 +146,10 @@ type MysqlConfUpdateByFile struct {
Database string `json:"database" validate:"required"`
File string `json:"file"`
}
type ChangeDBInfo struct {
ID uint `json:"id"`
From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=mysql mariadb"`
Type string `json:"type" validate:"required,oneof=mysql mariadb postgresql"`
Database string `json:"database" validate:"required"`
Value string `json:"value" validate:"required"`
}

View File

@ -0,0 +1,115 @@
package dto
import "time"
type PostgresqlDBSearch struct {
PageInfo
Info string `json:"info"`
Database string `json:"database" validate:"required"`
OrderBy string `json:"orderBy"`
Order string `json:"order"`
}
type PostgresqlDBInfo struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name"`
From string `json:"from"`
PostgresqlName string `json:"postgresqlName"`
Format string `json:"format"`
Username string `json:"username"`
Password string `json:"password"`
Permission string `json:"permission"`
BackupCount int `json:"backupCount"`
Description string `json:"description"`
}
type PostgresqlOption struct {
ID uint `json:"id"`
From string `json:"from"`
Type string `json:"type"`
Database string `json:"database"`
Name string `json:"name"`
}
type PostgresqlDBCreate struct {
Name string `json:"name" validate:"required"`
From string `json:"from" validate:"required,oneof=local remote"`
Database string `json:"database" validate:"required"`
Format string `json:"format"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Permission string `json:"permission" validate:"required"`
Description string `json:"description"`
}
type PostgresqlLoadDB struct {
From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=postgresql"`
Database string `json:"database" validate:"required"`
}
type PostgresqlDBDeleteCheck struct {
ID uint `json:"id" validate:"required"`
Type string `json:"type" validate:"required,oneof=postgresql"`
Database string `json:"database" validate:"required"`
}
type PostgresqlDBDelete struct {
ID uint `json:"id" validate:"required"`
Type string `json:"type" validate:"required,oneof=postgresql"`
Database string `json:"database" validate:"required"`
ForceDelete bool `json:"forceDelete"`
DeleteBackup bool `json:"deleteBackup"`
}
type PostgresqlStatus struct {
Uptime string `json:"uptime"`
Version string `json:"version"`
MaxConnections string `json:"max_connections"`
Autovacuum string `json:"autovacuum"`
CurrentConnections string `json:"current_connections"`
HitRatio string `json:"hit_ratio"`
SharedBuffers string `json:"shared_buffers"`
BuffersClean string `json:"buffers_clean"`
MaxwrittenClean string `json:"maxwritten_clean"`
BuffersBackendFsync string `json:"buffers_backend_fsync"`
}
type PostgresqlVariables struct {
BinlogCachSize string `json:"binlog_cache_size"`
InnodbBufferPoolSize string `json:"innodb_buffer_pool_size"`
InnodbLogBufferSize string `json:"innodb_log_buffer_size"`
JoinBufferSize string `json:"join_buffer_size"`
KeyBufferSize string `json:"key_buffer_size"`
MaxConnections string `json:"max_connections"`
MaxHeapTableSize string `json:"max_heap_table_size"`
QueryCacheSize string `json:"query_cache_size"`
QueryCache_type string `json:"query_cache_type"`
ReadBufferSize string `json:"read_buffer_size"`
ReadRndBufferSize string `json:"read_rnd_buffer_size"`
SortBufferSize string `json:"sort_buffer_size"`
TableOpenCache string `json:"table_open_cache"`
ThreadCacheSize string `json:"thread_cache_size"`
ThreadStack string `json:"thread_stack"`
TmpTableSize string `json:"tmp_table_size"`
SlowQueryLog string `json:"slow_query_log"`
LongQueryTime string `json:"long_query_time"`
}
type PostgresqlVariablesUpdate struct {
Type string `json:"type" validate:"required,oneof=postgresql"`
Database string `json:"database" validate:"required"`
Variables []PostgresqlVariablesUpdateHelper `json:"variables"`
}
type PostgresqlVariablesUpdateHelper struct {
Param string `json:"param"`
Value interface{} `json:"value"`
}
type PostgresqlConfUpdateByFile struct {
Type string `json:"type" validate:"required,oneof=postgresql mariadb"`
Database string `json:"database" validate:"required"`
File string `json:"file"`
}

View File

@ -72,6 +72,7 @@ type AppInstalledDTO struct {
}
type DatabaseConn struct {
Username string `json:"username"`
Password string `json:"password"`
ServiceName string `json:"serviceName"`
Port int64 `json:"port"`

View File

@ -0,0 +1,12 @@
package model
type DatabasePostgresql struct {
BaseModel
Name string `json:"name" gorm:"type:varchar(256);not null"`
From string `json:"from" gorm:"type:varchar(256);not null;default:local"`
PostgresqlName string `json:"postgresqlName" gorm:"type:varchar(64);not null"`
Format string `json:"format" gorm:"type:varchar(64);not null"`
Username string `json:"username" gorm:"type:varchar(256);not null"`
Password string `json:"password" gorm:"type:varchar(256);not null"`
Description string `json:"description" gorm:"type:varchar(256);"`
}

View File

@ -3,6 +3,7 @@ package repo
import (
"context"
"encoding/json"
"github.com/1Panel-dev/1Panel/backend/constant"
"gorm.io/gorm/clause"
@ -170,6 +171,7 @@ type RootInfo struct {
Env string `json:"env"`
Key string `json:"key"`
Version string `json:"version"`
AppPath string `json:"app_path"`
}
func (a *AppInstallRepo) LoadBaseInfo(key string, name string) (*RootInfo, error) {
@ -205,7 +207,7 @@ func (a *AppInstallRepo) LoadBaseInfo(key string, name string) (*RootInfo, error
if ok {
info.Password = password
}
case "mongodb", "postgresql":
case "mongodb", constant.AppPostgresql:
user, ok := envMap["PANEL_DB_ROOT_USER"].(string)
if ok {
info.UserName = user
@ -230,5 +232,7 @@ func (a *AppInstallRepo) LoadBaseInfo(key string, name string) (*RootInfo, error
info.Param = appInstall.Param
info.Version = appInstall.Version
info.Key = app.Key
appInstall.App = app
info.AppPath = appInstall.GetAppPath()
return &info, nil
}

View File

@ -0,0 +1,123 @@
package repo
import (
"context"
"fmt"
"github.com/1Panel-dev/1Panel/backend/app/model"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
"gorm.io/gorm"
)
type PostgresqlRepo struct{}
type IPostgresqlRepo interface {
Get(opts ...DBOption) (model.DatabasePostgresql, error)
WithByPostgresqlName(postgresqlName string) DBOption
WithByFrom(from string) DBOption
List(opts ...DBOption) ([]model.DatabasePostgresql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabasePostgresql, error)
Create(ctx context.Context, postgresql *model.DatabasePostgresql) error
Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error
}
func NewIPostgresqlRepo() IPostgresqlRepo {
return &PostgresqlRepo{}
}
func (u *PostgresqlRepo) Get(opts ...DBOption) (model.DatabasePostgresql, error) {
var postgresql model.DatabasePostgresql
db := global.DB
for _, opt := range opts {
db = opt(db)
}
if err := db.First(&postgresql).Error; err != nil {
return postgresql, err
}
pass, err := encrypt.StringDecrypt(postgresql.Password)
if err != nil {
global.LOG.Errorf("decrypt database db %s password failed, err: %v", postgresql.Name, err)
}
postgresql.Password = pass
return postgresql, err
}
func (u *PostgresqlRepo) List(opts ...DBOption) ([]model.DatabasePostgresql, error) {
var postgresqls []model.DatabasePostgresql
db := global.DB.Model(&model.DatabasePostgresql{})
for _, opt := range opts {
db = opt(db)
}
if err := db.Find(&postgresqls).Error; err != nil {
return postgresqls, err
}
for i := 0; i < len(postgresqls); i++ {
pass, err := encrypt.StringDecrypt(postgresqls[i].Password)
if err != nil {
global.LOG.Errorf("decrypt database db %s password failed, err: %v", postgresqls[i].Name, err)
}
postgresqls[i].Password = pass
}
return postgresqls, nil
}
func (u *PostgresqlRepo) Page(page, size int, opts ...DBOption) (int64, []model.DatabasePostgresql, error) {
var postgresqls []model.DatabasePostgresql
db := global.DB.Model(&model.DatabasePostgresql{})
for _, opt := range opts {
db = opt(db)
}
count := int64(0)
db = db.Count(&count)
if err := db.Limit(size).Offset(size * (page - 1)).Find(&postgresqls).Error; err != nil {
return count, postgresqls, err
}
for i := 0; i < len(postgresqls); i++ {
pass, err := encrypt.StringDecrypt(postgresqls[i].Password)
if err != nil {
global.LOG.Errorf("decrypt database db %s password failed, err: %v", postgresqls[i].Name, err)
}
postgresqls[i].Password = pass
}
return count, postgresqls, nil
}
func (u *PostgresqlRepo) Create(ctx context.Context, postgresql *model.DatabasePostgresql) error {
pass, err := encrypt.StringEncrypt(postgresql.Password)
if err != nil {
return fmt.Errorf("decrypt database db %s password failed, err: %v", postgresql.Name, err)
}
postgresql.Password = pass
return getTx(ctx).Create(postgresql).Error
}
func (u *PostgresqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabasePostgresql{}).Error
}
func (u *PostgresqlRepo) DeleteLocal(ctx context.Context) error {
return getTx(ctx).Where("`from` = ?", "local").Delete(&model.DatabasePostgresql{}).Error
}
func (u *PostgresqlRepo) Update(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.DatabasePostgresql{}).Where("id = ?", id).Updates(vars).Error
}
func (u *PostgresqlRepo) WithByPostgresqlName(postgresqlName string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("postgresql_name = ?", postgresqlName)
}
}
func (u *PostgresqlRepo) WithByFrom(from string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(from) != 0 {
return g.Where("`from` = ?", from)
}
return g
}
}

View File

@ -184,6 +184,7 @@ func (a *AppInstallService) LoadConnInfo(req dto.OperationWithNameAndType) (resp
if err != nil {
return data, nil
}
data.Username = app.UserName
data.Password = app.Password
data.ServiceName = app.ServiceName
data.Port = app.Port
@ -781,7 +782,7 @@ func updateInstallInfoInDB(appKey, appName, param string, isRestart bool, value
if err != nil {
return nil
}
envPath := fmt.Sprintf("%s/%s/%s/.env", constant.AppInstallDir, appKey, appInstall.Name)
envPath := fmt.Sprintf("%s/%s/.env", appInstall.AppPath, appInstall.Name)
lineBytes, err := os.ReadFile(envPath)
if err != nil {
return err

View File

@ -154,7 +154,7 @@ func createLink(ctx context.Context, app model.App, appInstall *model.AppInstall
}
switch app.Key {
case "mysql", "mariadb", "postgresql", "mongodb":
case "mysql", "mariadb", constant.AppPostgresql, "mongodb":
if password, ok := params["PANEL_DB_ROOT_PASSWORD"]; ok {
if password != "" {
database.Password = password.(string)
@ -233,6 +233,31 @@ func createLink(ctx context.Context, app model.App, appInstall *model.AppInstall
}
var resourceId uint
if dbConfig.DbName != "" && dbConfig.DbUser != "" && dbConfig.Password != "" {
switch database.Type {
case constant.AppPostgresql:
iPostgresqlRepo := repo.NewIPostgresqlRepo()
oldPostgresqlDb, _ := iPostgresqlRepo.Get(commonRepo.WithByName(dbConfig.DbName), iPostgresqlRepo.WithByFrom(constant.ResourceLocal))
resourceId = oldPostgresqlDb.ID
if oldPostgresqlDb.ID > 0 {
if oldPostgresqlDb.Username != dbConfig.DbUser || oldPostgresqlDb.Password != dbConfig.Password {
return buserr.New(constant.ErrDbUserNotValid)
}
} else {
var createPostgresql dto.PostgresqlDBCreate
createPostgresql.Name = dbConfig.DbName
createPostgresql.Username = dbConfig.DbUser
createPostgresql.Database = database.Name
createPostgresql.Format = "UTF8"
createPostgresql.Password = dbConfig.Password
createPostgresql.From = database.From
pgdb, err := NewIPostgresqlService().Create(ctx, createPostgresql)
if err != nil {
return err
}
resourceId = pgdb.ID
}
break
case "mysql", "mariadb":
iMysqlRepo := repo.NewIMysqlRepo()
oldMysqlDb, _ := iMysqlRepo.Get(commonRepo.WithByName(dbConfig.DbName), iMysqlRepo.WithByFrom(constant.ResourceLocal))
resourceId = oldMysqlDb.ID
@ -255,6 +280,10 @@ func createLink(ctx context.Context, app model.App, appInstall *model.AppInstall
}
resourceId = mysqldb.ID
}
break
}
}
var installResource model.AppInstallResource
installResource.ResourceId = resourceId
@ -364,8 +393,10 @@ func deleteLink(ctx context.Context, install *model.AppInstall, deleteDB bool, f
return nil
}
for _, re := range resources {
if deleteDB {
switch re.Key {
case constant.AppMysql, constant.AppMariaDB:
mysqlService := NewIMysqlService()
if (re.Key == constant.AppMysql || re.Key == constant.AppMariaDB) && deleteDB {
database, _ := mysqlRepo.Get(commonRepo.WithByID(re.ResourceId))
if reflect.DeepEqual(database, model.DatabaseMysql{}) {
continue
@ -379,7 +410,24 @@ func deleteLink(ctx context.Context, install *model.AppInstall, deleteDB bool, f
}); err != nil && !forceDelete {
return err
}
case constant.AppPostgresql:
pgsqlService := NewIPostgresqlService()
database, _ := postgresqlRepo.Get(commonRepo.WithByID(re.ResourceId))
if reflect.DeepEqual(database, model.DatabasePostgresql{}) {
continue
}
if err := pgsqlService.Delete(ctx, dto.PostgresqlDBDelete{
ID: database.ID,
ForceDelete: forceDelete,
DeleteBackup: deleteBackup,
Type: re.Key,
Database: database.PostgresqlName,
}); err != nil {
return err
}
}
}
}
return appInstallResourceRepo.DeleteBy(ctx, appInstallResourceRepo.WithAppInstallId(install.ID))
}

View File

@ -41,8 +41,11 @@ type IBackupService interface {
ListFiles(req dto.BackupSearchFile) ([]string, error)
MysqlBackup(db dto.CommonBackup) error
PostgresqlBackup(db dto.CommonBackup) error
MysqlRecover(db dto.CommonRecover) error
PostgresqlRecover(db dto.CommonRecover) error
MysqlRecoverByUpload(req dto.CommonRecover) error
PostgresqlRecoverByUpload(req dto.CommonRecover) error
RedisBackup() error
RedisRecover(db dto.CommonRecover) error

View File

@ -107,7 +107,8 @@ func handleAppBackup(install *model.AppInstall, backupDir, fileName string) erro
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
for _, resource := range resources {
if resource.Key == "mysql" || resource.Key == "mariadb" {
switch resource.Key {
case constant.AppMysql, constant.AppMariaDB:
db, err := mysqlRepo.Get(commonRepo.WithByID(resource.ResourceId))
if err != nil {
return err
@ -115,6 +116,14 @@ func handleAppBackup(install *model.AppInstall, backupDir, fileName string) erro
if err := handleMysqlBackup(db.MysqlName, db.Name, tmpDir, fmt.Sprintf("%s.sql.gz", install.Name)); err != nil {
return err
}
case constant.AppPostgresql:
db, err := postgresqlRepo.Get(commonRepo.WithByID(resource.ResourceId))
if err != nil {
return err
}
if err := handlePostgresqlBackup(db.PostgresqlName, db.Name, tmpDir, fmt.Sprintf("%s.sql.gz", install.Name)); err != nil {
return err
}
}
}
@ -191,6 +200,34 @@ func handleAppRecover(install *model.AppInstall, recoverFile string, isRollback
return err
}
}
if database.Type == constant.AppPostgresql {
db, err := postgresqlRepo.Get(commonRepo.WithByID(resource.ResourceId))
if err != nil {
return err
}
newDB, envMap, err := reCreatePostgresqlDB(db.ID, database, oldInstall.Env)
if err != nil {
return err
}
oldHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", envMap["PANEL_DB_HOST"].(string))
newHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", database.Address)
oldInstall.Env = strings.ReplaceAll(oldInstall.Env, oldHost, newHost)
envMap["PANEL_DB_HOST"] = database.Address
newEnvFile, err = coverEnvJsonToStr(oldInstall.Env)
if err != nil {
return err
}
_ = appInstallResourceRepo.BatchUpdateBy(map[string]interface{}{"resource_id": newDB.ID}, commonRepo.WithByID(resource.ID))
if err := handlePostgresqlRecover(dto.CommonRecover{
Name: newDB.PostgresqlName,
DetailName: newDB.Name,
File: fmt.Sprintf("%s/%s.sql.gz", tmpPath, install.Name),
}, true); err != nil {
global.LOG.Errorf("handle recover from sql.gz failed, err: %v", err)
return err
}
}
if database.Type == "mysql" || database.Type == "mariadb" {
db, err := mysqlRepo.Get(commonRepo.WithByID(resource.ResourceId))
if err != nil {
@ -235,7 +272,7 @@ func handleAppRecover(install *model.AppInstall, recoverFile string, isRollback
_ = fileOp.DeleteDir(backPath)
if len(newEnvFile) != 0 {
envPath := fmt.Sprintf("%s/%s/%s/.env", constant.AppInstallDir, install.App.Key, install.Name)
envPath := fmt.Sprintf("%s/%s/.env", install.GetAppPath(), install.Name)
file, err := os.OpenFile(envPath, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
@ -257,7 +294,32 @@ func handleAppRecover(install *model.AppInstall, recoverFile string, isRollback
return nil
}
func reCreatePostgresqlDB(dbID uint, database model.Database, oldEnv string) (*model.DatabasePostgresql, map[string]interface{}, error) {
postgresqlService := NewIPostgresqlService()
ctx := context.Background()
_ = postgresqlService.Delete(ctx, dto.PostgresqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true})
envMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(oldEnv), &envMap); err != nil {
return nil, envMap, err
}
oldName, _ := envMap["PANEL_DB_NAME"].(string)
oldUser, _ := envMap["PANEL_DB_USER"].(string)
oldPassword, _ := envMap["PANEL_DB_USER_PASSWORD"].(string)
createDB, err := postgresqlService.Create(context.Background(), dto.PostgresqlDBCreate{
Name: oldName,
From: database.From,
Database: database.Name,
Format: "UTF8",
Username: oldUser,
Password: oldPassword,
Permission: "%",
})
if err != nil {
return nil, envMap, err
}
return createDB, envMap, nil
}
func reCreateDB(dbID uint, database model.Database, oldEnv string) (*model.DatabaseMysql, map[string]interface{}, error) {
mysqlService := NewIMysqlService()
ctx := context.Background()

View File

@ -0,0 +1,180 @@
package service
import (
"fmt"
"github.com/1Panel-dev/1Panel/backend/buserr"
pgclient "github.com/1Panel-dev/1Panel/backend/utils/postgresql/client"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/app/model"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/utils/files"
"github.com/1Panel-dev/1Panel/backend/utils/postgresql/client"
)
func (u *BackupService) PostgresqlBackup(req dto.CommonBackup) error {
localDir, err := loadLocalDir()
if err != nil {
return err
}
timeNow := time.Now().Format("20060102150405")
targetDir := path.Join(localDir, fmt.Sprintf("database/%s/%s/%s", req.Type, req.Name, req.DetailName))
fileName := fmt.Sprintf("%s_%s.sql.gz", req.DetailName, timeNow)
if err := handlePostgresqlBackup(req.Name, req.DetailName, targetDir, fileName); err != nil {
return err
}
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
DetailName: req.DetailName,
Source: "LOCAL",
BackupType: "LOCAL",
FileDir: targetDir,
FileName: fileName,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
}
return nil
}
func (u *BackupService) PostgresqlRecover(req dto.CommonRecover) error {
if err := handlePostgresqlRecover(req, false); err != nil {
return err
}
return nil
}
func (u *BackupService) PostgresqlRecoverByUpload(req dto.CommonRecover) error {
file := req.File
fileName := path.Base(req.File)
if strings.HasSuffix(fileName, ".tar.gz") {
fileNameItem := time.Now().Format("20060102150405")
dstDir := fmt.Sprintf("%s/%s", path.Dir(req.File), fileNameItem)
if _, err := os.Stat(dstDir); err != nil && os.IsNotExist(err) {
if err = os.MkdirAll(dstDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", dstDir, err)
}
}
if err := handleUnTar(req.File, dstDir); err != nil {
_ = os.RemoveAll(dstDir)
return err
}
global.LOG.Infof("decompress file %s successful, now start to check test.sql is exist", req.File)
hasTestSql := false
_ = filepath.Walk(dstDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && info.Name() == "test.sql" {
hasTestSql = true
file = path
fileName = "test.sql"
}
return nil
})
if !hasTestSql {
_ = os.RemoveAll(dstDir)
return fmt.Errorf("no such file named test.sql in %s", fileName)
}
defer func() {
_ = os.RemoveAll(dstDir)
}()
}
req.File = path.Dir(file) + "/" + fileName
if err := handlePostgresqlRecover(req, false); err != nil {
return err
}
global.LOG.Info("recover from uploads successful!")
return nil
}
func handlePostgresqlBackup(database, dbName, targetDir, fileName string) error {
dbInfo, err := postgresqlRepo.Get(commonRepo.WithByName(dbName), postgresqlRepo.WithByPostgresqlName(database))
if err != nil {
return err
}
cli, _, err := LoadPostgresqlClientByFrom(database)
if err != nil {
return err
}
backupInfo := pgclient.BackupInfo{
Name: dbName,
Format: dbInfo.Format,
TargetDir: targetDir,
FileName: fileName,
Timeout: 300,
}
if err := cli.Backup(backupInfo); err != nil {
return err
}
return nil
}
func handlePostgresqlRecover(req dto.CommonRecover, isRollback bool) error {
isOk := false
fileOp := files.NewFileOp()
if !fileOp.Stat(req.File) {
return buserr.WithName("ErrFileNotFound", req.File)
}
dbInfo, err := postgresqlRepo.Get(commonRepo.WithByName(req.DetailName), postgresqlRepo.WithByPostgresqlName(req.Name))
if err != nil {
return err
}
cli, _, err := LoadPostgresqlClientByFrom(req.Name)
if err != nil {
return err
}
if !isRollback {
rollbackFile := path.Join(global.CONF.System.TmpDir, fmt.Sprintf("database/%s/%s_%s.sql.gz", req.Type, req.DetailName, time.Now().Format("20060102150405")))
if err := cli.Backup(client.BackupInfo{
Name: req.DetailName,
Format: dbInfo.Format,
TargetDir: path.Dir(rollbackFile),
FileName: path.Base(rollbackFile),
Timeout: 300,
}); err != nil {
return fmt.Errorf("backup postgresql db %s for rollback before recover failed, err: %v", req.DetailName, err)
}
defer func() {
if !isOk {
global.LOG.Info("recover failed, start to rollback now")
if err := cli.Recover(client.RecoverInfo{
Name: req.DetailName,
Format: dbInfo.Format,
SourceFile: rollbackFile,
Timeout: 300,
}); err != nil {
global.LOG.Errorf("rollback postgresql db %s from %s failed, err: %v", req.DetailName, rollbackFile, err)
}
global.LOG.Infof("rollback postgresql db %s from %s successful", req.DetailName, rollbackFile)
_ = os.RemoveAll(rollbackFile)
} else {
_ = os.RemoveAll(rollbackFile)
}
}()
}
if err := cli.Recover(client.RecoverInfo{
Name: req.DetailName,
Format: dbInfo.Format,
SourceFile: req.File,
Username: dbInfo.Username,
Timeout: 300,
}); err != nil {
return err
}
isOk = true
return nil
}

View File

@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"github.com/1Panel-dev/1Panel/backend/utils/postgresql"
client2 "github.com/1Panel-dev/1Panel/backend/utils/postgresql/client"
"os"
"path"
@ -78,7 +80,55 @@ func (u *DatabaseService) List(dbType string) ([]dto.DatabaseOption, error) {
}
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
if _, err := mysql.NewMysqlClient(client.DBInfo{
switch req.Type {
case constant.AppPostgresql:
_, err := postgresql.NewPostgresqlClient(client2.DBInfo{
From: "remote",
Address: req.Address,
Port: req.Port,
Username: req.Username,
Password: req.Password,
SSL: false,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6,
})
return err == nil
case "mysql", "mariadb":
_, err := mysql.NewMysqlClient(client.DBInfo{
From: "remote",
Address: req.Address,
Port: req.Port,
Username: req.Username,
Password: req.Password,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6,
})
return err == nil
}
return false
}
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
db, _ := databaseRepo.Get(commonRepo.WithByName(req.Name))
if db.ID != 0 {
if db.From == "local" {
return buserr.New(constant.ErrLocalExist)
}
return constant.ErrRecordExist
}
switch req.Type {
case constant.AppPostgresql:
if _, err := postgresql.NewPostgresqlClient(client2.DBInfo{
From: "remote",
Address: req.Address,
Port: req.Port,
@ -92,19 +142,9 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
SkipVerify: req.SkipVerify,
Timeout: 6,
}); err != nil {
return false
}
return true
}
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
db, _ := databaseRepo.Get(commonRepo.WithByName(req.Name))
if db.ID != 0 {
if db.From == "local" {
return buserr.New(constant.ErrLocalExist)
}
return constant.ErrRecordExist
return err
}
case "mysql", "mariadb":
if _, err := mysql.NewMysqlClient(client.DBInfo{
From: "remote",
Address: req.Address,
@ -121,6 +161,10 @@ func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
}); err != nil {
return err
}
default:
return errors.New("database type not supported")
}
if err := copier.Copy(&db, &req); err != nil {
return errors.WithMessage(constant.ErrStructTransform, err.Error())
}
@ -178,6 +222,25 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
}
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
switch req.Type {
case constant.AppPostgresql:
if _, err := postgresql.NewPostgresqlClient(client2.DBInfo{
From: "remote",
Address: req.Address,
Port: req.Port,
Username: req.Username,
Password: req.Password,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 300,
}); err != nil {
return err
}
case "mysql", "mariadb":
if _, err := mysql.NewMysqlClient(client.DBInfo{
From: "remote",
Address: req.Address,
@ -186,15 +249,17 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
Password: req.Password,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
RootCert: req.RootCert,
SkipVerify: req.SkipVerify,
Timeout: 300,
}); err != nil {
return err
}
default:
return errors.New("database type not supported")
}
pass, err := encrypt.StringEncrypt(req.Password)
if err != nil {
@ -208,6 +273,7 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
upMap["port"] = req.Port
upMap["username"] = req.Username
upMap["password"] = pass
upMap["description"] = req.Description
upMap["ssl"] = req.SSL
upMap["client_key"] = req.ClientKey
upMap["client_cert"] = req.ClientCert

View File

@ -0,0 +1,461 @@
package service
import (
"bufio"
"context"
"fmt"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/app/model"
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/utils/cmd"
"github.com/1Panel-dev/1Panel/backend/utils/compose"
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
"github.com/1Panel-dev/1Panel/backend/utils/postgresql"
"github.com/1Panel-dev/1Panel/backend/utils/postgresql/client"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jinzhu/copier"
"github.com/pkg/errors"
"os"
"path"
)
type PostgresqlService struct{}
type IPostgresqlService interface {
SearchWithPage(search dto.PostgresqlDBSearch) (int64, interface{}, error)
ListDBOption() ([]dto.PostgresqlOption, error)
Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error)
LoadFromRemote(req dto.PostgresqlLoadDB) error
ChangeAccess(info dto.ChangeDBInfo) error
ChangePassword(info dto.ChangeDBInfo) error
UpdateVariables(req dto.PostgresqlVariablesUpdate) error
UpdateConfByFile(info dto.PostgresqlConfUpdateByFile) error
UpdateDescription(req dto.UpdateDescription) error
DeleteCheck(req dto.PostgresqlDBDeleteCheck) ([]string, error)
Delete(ctx context.Context, req dto.PostgresqlDBDelete) error
LoadStatus(req dto.OperationWithNameAndType) (*dto.PostgresqlStatus, error)
LoadVariables(req dto.OperationWithNameAndType) (*dto.PostgresqlVariables, error)
LoadBaseInfo(req dto.OperationWithNameAndType) (*dto.DBBaseInfo, error)
LoadRemoteAccess(req dto.OperationWithNameAndType) (bool, error)
LoadDatabaseFile(req dto.OperationWithNameAndType) (string, error)
}
func NewIPostgresqlService() IPostgresqlService {
return &PostgresqlService{}
}
func (u *PostgresqlService) SearchWithPage(search dto.PostgresqlDBSearch) (int64, interface{}, error) {
total, postgresqls, err := postgresqlRepo.Page(search.Page, search.PageSize,
postgresqlRepo.WithByPostgresqlName(search.Database),
commonRepo.WithLikeName(search.Info),
commonRepo.WithOrderRuleBy(search.OrderBy, search.Order),
)
var dtoPostgresqls []dto.PostgresqlDBInfo
for _, pg := range postgresqls {
var item dto.PostgresqlDBInfo
if err := copier.Copy(&item, &pg); err != nil {
return 0, nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
}
dtoPostgresqls = append(dtoPostgresqls, item)
}
return total, dtoPostgresqls, err
}
func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, error) {
postgresqls, err := postgresqlRepo.List()
if err != nil {
return nil, err
}
databases, err := databaseRepo.GetList(databaseRepo.WithTypeList("postgresql,mariadb"))
if err != nil {
return nil, err
}
var dbs []dto.PostgresqlOption
for _, pg := range postgresqls {
var item dto.PostgresqlOption
if err := copier.Copy(&item, &pg); err != nil {
return nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
}
item.Database = pg.PostgresqlName
for _, database := range databases {
if database.Name == item.Database {
item.Type = database.Type
}
}
dbs = append(dbs, item)
}
return dbs, err
}
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) {
return nil, buserr.New(constant.ErrCmdIllegal)
}
pgsql, _ := postgresqlRepo.Get(commonRepo.WithByName(req.Name), postgresqlRepo.WithByPostgresqlName(req.Database), databaseRepo.WithByFrom(req.From))
if pgsql.ID != 0 {
return nil, constant.ErrRecordExist
}
var createItem model.DatabasePostgresql
if err := copier.Copy(&createItem, &req); err != nil {
return nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
}
if req.From == "local" && req.Username == "root" {
return nil, errors.New("Cannot set root as user name")
}
cli, version, err := LoadPostgresqlClientByFrom(req.Database)
if err != nil {
return nil, err
}
createItem.PostgresqlName = req.Database
defer cli.Close()
if err := cli.Create(client.CreateInfo{
Name: req.Name,
Format: req.Format,
Username: req.Username,
Password: req.Password,
Version: version,
Timeout: 300,
}); err != nil {
return nil, err
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := postgresqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
func LoadPostgresqlClientByFrom(database string) (postgresql.PostgresqlClient, string, error) {
var (
dbInfo client.DBInfo
version string
err error
)
dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(commonRepo.WithByName(database))
if err != nil {
return nil, "", err
}
dbInfo.From = databaseItem.From
dbInfo.Database = database
if dbInfo.From != "local" {
dbInfo.Address = databaseItem.Address
dbInfo.Port = databaseItem.Port
dbInfo.Username = databaseItem.Username
dbInfo.Password = databaseItem.Password
dbInfo.SSL = databaseItem.SSL
dbInfo.ClientKey = databaseItem.ClientKey
dbInfo.ClientCert = databaseItem.ClientCert
dbInfo.RootCert = databaseItem.RootCert
dbInfo.SkipVerify = databaseItem.SkipVerify
version = databaseItem.Version
} else {
app, err := appInstallRepo.LoadBaseInfo(databaseItem.Type, database)
if err != nil {
return nil, "", err
}
dbInfo.From = "local"
dbInfo.Address = app.ContainerName
dbInfo.Username = app.UserName
dbInfo.Password = app.Password
dbInfo.Port = uint(app.Port)
}
cli, err := postgresql.NewPostgresqlClient(dbInfo)
if err != nil {
return nil, "", err
}
return cli, version, nil
}
func (u *PostgresqlService) LoadFromRemote(req dto.PostgresqlLoadDB) error {
return nil
}
func (u *PostgresqlService) UpdateDescription(req dto.UpdateDescription) error {
return postgresqlRepo.Update(req.ID, map[string]interface{}{"description": req.Description})
}
func (u *PostgresqlService) DeleteCheck(req dto.PostgresqlDBDeleteCheck) ([]string, error) {
var appInUsed []string
db, err := postgresqlRepo.Get(commonRepo.WithByID(req.ID))
if err != nil {
return appInUsed, err
}
if db.From == "local" {
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Database)
if err != nil {
return appInUsed, err
}
apps, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithLinkId(app.ID), appInstallResourceRepo.WithResourceId(db.ID))
for _, app := range apps {
appInstall, _ := appInstallRepo.GetFirst(commonRepo.WithByID(app.AppInstallId))
if appInstall.ID != 0 {
appInUsed = append(appInUsed, appInstall.Name)
}
}
} else {
apps, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithResourceId(db.ID))
for _, app := range apps {
appInstall, _ := appInstallRepo.GetFirst(commonRepo.WithByID(app.AppInstallId))
if appInstall.ID != 0 {
appInUsed = append(appInUsed, appInstall.Name)
}
}
}
return appInUsed, nil
}
func (u *PostgresqlService) Delete(ctx context.Context, req dto.PostgresqlDBDelete) error {
db, err := postgresqlRepo.Get(commonRepo.WithByID(req.ID))
if err != nil && !req.ForceDelete {
return err
}
cli, version, err := LoadPostgresqlClientByFrom(req.Database)
if err != nil {
return err
}
defer cli.Close()
if err := cli.Delete(client.DeleteInfo{
Name: db.Name,
Version: version,
Username: db.Username,
Permission: "",
Timeout: 300,
}); err != nil && !req.ForceDelete {
return err
}
if req.DeleteBackup {
uploadDir := path.Join(global.CONF.System.BaseDir, fmt.Sprintf("1panel/uploads/database/%s/%s/%s", req.Type, req.Database, db.Name))
if _, err := os.Stat(uploadDir); err == nil {
_ = os.RemoveAll(uploadDir)
}
localDir, err := loadLocalDir()
if err != nil && !req.ForceDelete {
return err
}
backupDir := path.Join(localDir, fmt.Sprintf("database/%s/%s/%s", req.Type, db.PostgresqlName, db.Name))
if _, err := os.Stat(backupDir); err == nil {
_ = os.RemoveAll(backupDir)
}
_ = backupRepo.DeleteRecord(ctx, commonRepo.WithByType(req.Type), commonRepo.WithByName(req.Database), backupRepo.WithByDetailName(db.Name))
global.LOG.Infof("delete database %s-%s backups successful", req.Database, db.Name)
}
_ = postgresqlRepo.Delete(ctx, commonRepo.WithByID(db.ID))
return nil
}
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) {
return buserr.New(constant.ErrCmdIllegal)
}
cli, version, err := LoadPostgresqlClientByFrom(req.Database)
if err != nil {
return err
}
defer cli.Close()
var (
postgresqlData model.DatabasePostgresql
passwordInfo client.PasswordChangeInfo
)
passwordInfo.Password = req.Value
passwordInfo.Timeout = 300
passwordInfo.Version = version
if req.ID != 0 {
postgresqlData, err = postgresqlRepo.Get(commonRepo.WithByID(req.ID))
if err != nil {
return err
}
passwordInfo.Name = postgresqlData.Name
passwordInfo.Username = postgresqlData.Username
} else {
dbItem, err := databaseRepo.Get(commonRepo.WithByType(req.Type), commonRepo.WithByFrom(req.From))
if err != nil {
return err
}
passwordInfo.Username = dbItem.Username
}
if err := cli.ChangePassword(passwordInfo); err != nil {
return err
}
if req.ID != 0 {
var appRess []model.AppInstallResource
if req.From == "local" {
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Database)
if err != nil {
return err
}
appRess, _ = appInstallResourceRepo.GetBy(appInstallResourceRepo.WithLinkId(app.ID), appInstallResourceRepo.WithResourceId(postgresqlData.ID))
} else {
appRess, _ = appInstallResourceRepo.GetBy(appInstallResourceRepo.WithResourceId(postgresqlData.ID))
}
for _, appRes := range appRess {
appInstall, err := appInstallRepo.GetFirst(commonRepo.WithByID(appRes.AppInstallId))
if err != nil {
return err
}
appModel, err := appRepo.GetFirst(commonRepo.WithByID(appInstall.AppId))
if err != nil {
return err
}
global.LOG.Infof("start to update postgresql password used by app %s-%s", appModel.Key, appInstall.Name)
if err := updateInstallInfoInDB(appModel.Key, appInstall.Name, "user-password", true, req.Value); err != nil {
return err
}
}
global.LOG.Info("execute password change sql successful")
pass, err := encrypt.StringEncrypt(req.Value)
if err != nil {
return fmt.Errorf("decrypt database db password failed, err: %v", err)
}
_ = postgresqlRepo.Update(postgresqlData.ID, map[string]interface{}{"password": pass})
return nil
}
if err := updateInstallInfoInDB(req.Type, req.Database, "password", false, req.Value); err != nil {
return err
}
return nil
}
func (u *PostgresqlService) ChangeAccess(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) {
return buserr.New(constant.ErrCmdIllegal)
}
cli, version, err := LoadPostgresqlClientByFrom(req.Database)
if err != nil {
return err
}
defer cli.Close()
var (
postgresqlData model.DatabasePostgresql
accessInfo client.AccessChangeInfo
)
accessInfo.Permission = req.Value
accessInfo.Timeout = 300
accessInfo.Version = version
if req.ID != 0 {
postgresqlData, err = postgresqlRepo.Get(commonRepo.WithByID(req.ID))
if err != nil {
return err
}
accessInfo.Name = postgresqlData.Name
accessInfo.Username = postgresqlData.Username
accessInfo.Password = postgresqlData.Password
} else {
accessInfo.Username = "root"
}
if err := cli.ChangeAccess(accessInfo); err != nil {
return err
}
if postgresqlData.ID != 0 {
_ = postgresqlRepo.Update(postgresqlData.ID, map[string]interface{}{"permission": req.Value})
}
return nil
}
func (u *PostgresqlService) UpdateConfByFile(req dto.PostgresqlConfUpdateByFile) error {
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Database)
if err != nil {
return err
}
conf := fmt.Sprintf("%s/%s/%s/data/postgresql.conf", constant.AppInstallDir, req.Type, app.Name)
file, err := os.OpenFile(conf, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer file.Close()
write := bufio.NewWriter(file)
_, _ = write.WriteString(req.File)
write.Flush()
cli, _, err := LoadPostgresqlClientByFrom(req.Database)
if err != nil {
return err
}
defer cli.Close()
if _, err := compose.Restart(fmt.Sprintf("%s/%s/%s/docker-compose.yml", constant.AppInstallDir, req.Type, app.Name)); err != nil {
return err
}
return nil
}
func (u *PostgresqlService) UpdateVariables(req dto.PostgresqlVariablesUpdate) error {
return nil
}
func (u *PostgresqlService) LoadBaseInfo(req dto.OperationWithNameAndType) (*dto.DBBaseInfo, error) {
var data dto.DBBaseInfo
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Name)
if err != nil {
return nil, err
}
data.ContainerName = app.ContainerName
data.Name = app.Name
data.Port = int64(app.Port)
return &data, nil
}
func (u *PostgresqlService) LoadRemoteAccess(req dto.OperationWithNameAndType) (bool, error) {
return true, nil
}
func (u *PostgresqlService) LoadVariables(req dto.OperationWithNameAndType) (*dto.PostgresqlVariables, error) {
return nil, nil
}
func (u *PostgresqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.PostgresqlStatus, error) {
app, err := appInstallRepo.LoadBaseInfo(req.Type, req.Name)
if err != nil {
return nil, err
}
cli, _, err := LoadPostgresqlClientByFrom(app.Name)
if err != nil {
return nil, err
}
defer cli.Close()
status := cli.Status()
postgresqlStatus := dto.PostgresqlStatus{}
copier.Copy(&postgresqlStatus,&status)
return &postgresqlStatus, nil
}
func (u *PostgresqlService) LoadDatabaseFile(req dto.OperationWithNameAndType) (string, error) {
filePath := ""
switch req.Type {
case "postgresql-conf":
filePath = path.Join(global.CONF.System.DataDir, fmt.Sprintf("apps/postgresql/%s/data/postgresql.conf", req.Name))
}
if _, err := os.Stat(filePath); err != nil {
return "", buserr.New("ErrHttpReqNotFound")
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}

View File

@ -13,6 +13,7 @@ var (
appInstallResourceRepo = repo.NewIAppInstallResourceRpo()
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
databaseRepo = repo.NewIDatabaseRepo()
imageRepoRepo = repo.NewIImageRepoRepo()

View File

@ -22,6 +22,7 @@ const (
AppOpenresty = "openresty"
AppMysql = "mysql"
AppMariaDB = "mariadb"
AppPostgresql = "postgresql"
AppRedis = "redis"
AppResourceLocal = "local"

View File

@ -19,6 +19,7 @@ func Init() {
migrations.AddTableImageRepo,
migrations.AddTableWebsite,
migrations.AddTableDatabaseMysql,
migrations.AddTableDatabasePostgresql,
migrations.AddTableSnap,
migrations.AddDefaultGroup,
migrations.AddTableRuntime,

View File

@ -214,7 +214,12 @@ var AddTableDatabaseMysql = &gormigrate.Migration{
return tx.AutoMigrate(&model.DatabaseMysql{})
},
}
var AddTableDatabasePostgresql = &gormigrate.Migration{
ID: "20231224-add-table-database_postgresql",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.DatabasePostgresql{})
},
}
var AddTableWebsite = &gormigrate.Migration{
ID: "20201009-add-table-website",
Migrate: func(tx *gorm.DB) error {

View File

@ -52,5 +52,16 @@ func (s *DatabaseRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.POST("/db/search", baseApi.SearchDatabase)
cmdRouter.POST("/db/del/check", baseApi.DeleteCheckDatabase)
cmdRouter.POST("/db/del", baseApi.DeleteDatabase)
//PGSQL管理系列接口
cmdRouter.POST("/pg", baseApi.CreatePostgresql)
cmdRouter.POST("/pg/search", baseApi.SearchPostgresql)
cmdRouter.POST("/pg/del/check", baseApi.DeleteCheckPostgresql)
cmdRouter.POST("/pg/password", baseApi.ChangePostgresqlPassword)
cmdRouter.POST("/pg/description", baseApi.UpdatePostgresqlDescription)
cmdRouter.POST("/pg/del", baseApi.DeletePostgresql)
cmdRouter.POST("/pg/conf", baseApi.UpdatePostgresqlConfByFile)
cmdRouter.POST("/pg/status", baseApi.LoadPostgresqlStatus)
}
}

View File

@ -61,7 +61,12 @@ func (s webDAVClient) Download(src, target string) (bool, error) {
if err != nil {
return false, err
}
targetStat, err := os.Stat(target)
if err == nil {
if info.Size() == targetStat.Size() {
return true, nil
}
}
file, err := os.Create(target)
if err != nil {
return false, err

View File

@ -0,0 +1,64 @@
package postgresql
import (
"context"
"database/sql"
"fmt"
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/1Panel-dev/1Panel/backend/utils/postgresql/client"
_ "github.com/jackc/pgx/v5/stdlib"
"time"
)
type PostgresqlClient interface {
Create(info client.CreateInfo) error
Delete(info client.DeleteInfo) error
ReloadConf()error
ChangePassword(info client.PasswordChangeInfo) error
ChangeAccess(info client.AccessChangeInfo) error
Backup(info client.BackupInfo) error
Recover(info client.RecoverInfo) error
Status() client.Status
SyncDB(version string) ([]client.SyncDBInfo, error)
Close()
}
func NewPostgresqlClient(conn client.DBInfo) (PostgresqlClient, error) {
if conn.Port==0 {
conn.Port=5432
}
if conn.From == "local" {
conn.Address = "127.0.0.1"
}
connArgs := fmt.Sprintf("postgres://%s:%s@%s:%d/?sslmode=disable", conn.Username, conn.Password, conn.Address, conn.Port)
db, err := sql.Open("pgx", connArgs)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(conn.Timeout)*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
if ctx.Err() == context.DeadlineExceeded {
return nil, buserr.New(constant.ErrExecTimeOut)
}
return client.NewRemote(client.Remote{
Client: db,
Database: conn.Database,
User: conn.Username,
Password: conn.Password,
Address: conn.Address,
Port: conn.Port,
SSL: conn.SSL,
RootCert: conn.RootCert,
ClientKey: conn.ClientKey,
ClientCert: conn.ClientCert,
SkipVerify: conn.SkipVerify,
}), nil
}

View File

@ -0,0 +1,102 @@
package client
import (
_ "github.com/jackc/pgx/v5/stdlib"
)
type DBInfo struct {
From string `json:"from"`
Database string `json:"database"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"userName"`
Password string `json:"password"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert"`
ClientKey string `json:"clientKey"`
ClientCert string `json:"clientCert"`
SkipVerify bool `json:"skipVerify"`
Timeout uint `json:"timeout"` // second
}
type CreateInfo struct {
Name string `json:"name"`
Format string `json:"format"`
Version string `json:"version"`
Username string `json:"userName"`
Password string `json:"password"`
Permission string `json:"permission"`
Timeout uint `json:"timeout"` // second
}
type DeleteInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Username string `json:"userName"`
Permission string `json:"permission"`
ForceDelete bool `json:"forceDelete"`
Timeout uint `json:"timeout"` // second
}
type PasswordChangeInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Username string `json:"userName"`
Password string `json:"password"`
Permission string `json:"permission"`
Timeout uint `json:"timeout"` // second
}
type AccessChangeInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Username string `json:"userName"`
Password string `json:"password"`
OldPermission string `json:"oldPermission"`
Permission string `json:"permission"`
Timeout uint `json:"timeout"` // second
}
type BackupInfo struct {
Name string `json:"name"`
Format string `json:"format"`
TargetDir string `json:"targetDir"`
FileName string `json:"fileName"`
Timeout uint `json:"timeout"` // second
}
type RecoverInfo struct {
Name string `json:"name"`
Format string `json:"format"`
SourceFile string `json:"sourceFile"`
Username string `json:"username"`
Timeout uint `json:"timeout"` // second
}
type SyncDBInfo struct {
Name string `json:"name"`
From string `json:"from"`
PostgresqlName string `json:"postgresqlName"`
Format string `json:"format"`
Username string `json:"username"`
Password string `json:"password"`
}
type Status struct {
Uptime string `json:"uptime"`
Version string `json:"version"`
MaxConnections string `json:"max_connections"`
Autovacuum string `json:"autovacuum"`
CurrentConnections string `json:"current_connections"`
HitRatio string `json:"hit_ratio"`
SharedBuffers string `json:"shared_buffers"`
BuffersClean string `json:"buffers_clean"`
MaxwrittenClean string `json:"maxwritten_clean"`
BuffersBackendFsync string `json:"buffers_backend_fsync"`
}

View File

@ -0,0 +1,232 @@
package client
import (
"bufio"
"context"
"database/sql"
"fmt"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/pkg/errors"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/1Panel-dev/1Panel/backend/utils/files"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Remote struct {
Client *sql.DB
Database string
User string
Password string
Address string
Port uint
SSL bool
RootCert string
ClientKey string
ClientCert string
SkipVerify bool
}
func NewRemote(db Remote) *Remote {
return &db
}
func (r *Remote) Status() Status {
status := Status{}
var i int64
var s string
var f float64
_ = r.Client.QueryRow("select count(*) from pg_stat_activity WHERE client_addr is not NULL;").Scan(&i)
status.CurrentConnections = fmt.Sprintf("%d",i)
_ = r.Client.QueryRow("SELECT current_timestamp - pg_postmaster_start_time();").Scan(&s)
before,_, _ := strings.Cut(s, ".")
status.Uptime = before
_ = r.Client.QueryRow("select sum(blks_hit)*100/sum(blks_hit+blks_read) as hit_ratio from pg_stat_database;").Scan(&f)
status.HitRatio = fmt.Sprintf("%0.2f",f)
var a1,a2,a3 int64
_ = r.Client.QueryRow("select buffers_clean, maxwritten_clean, buffers_backend_fsync from pg_stat_bgwriter;").Scan(&a1, &a2, &a3)
status.BuffersClean = fmt.Sprintf("%d",a1)
status.MaxwrittenClean = fmt.Sprintf("%d",a2)
status.BuffersBackendFsync= fmt.Sprintf("%d",a3)
rows, err := r.Client.Query("SHOW ALL;")
if err == nil {
defer rows.Close()
for rows.Next() {
var k,v string
err := rows.Scan(&k, &v,&s)
if err != nil {
continue
}
if k == "autovacuum" {
status.Autovacuum = v
}
if k == "max_connections" {
status.MaxConnections = v
}
if k == "server_version" {
status.Version = v
}
if k == "shared_buffers" {
status.SharedBuffers = v
}
}
}
return status
}
func (r *Remote) Create(info CreateInfo) error {
createUser := fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s';`, info.Username, info.Password)
createDB := fmt.Sprintf(`CREATE DATABASE "%s" OWNER "%s";`, info.Name, info.Username)
grant := fmt.Sprintf(`GRANT ALL PRIVILEGES ON DATABASE "%s" TO "%s";`, info.Name, info.Username)
if err := r.ExecSQL(createUser, info.Timeout); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "already") {
return buserr.New(constant.ErrUserIsExist)
}
return err
}
if err := r.ExecSQL(createDB, info.Timeout); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "already") {
_ = r.ExecSQL(fmt.Sprintf(`DROP DATABASE "%s"`, info.Name), info.Timeout)
return buserr.New(constant.ErrDatabaseIsExist)
}
return err
}
_ = r.ExecSQL(grant, info.Timeout)
return nil
}
func (r *Remote) CreateUser(info CreateInfo, withDeleteDB bool) error {
sql1 := fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s';
GRANT ALL PRIVILEGES ON DATABASE "%s" TO "%s";`, info.Username, info.Password, info.Name, info.Username)
err := r.ExecSQL(sql1, info.Timeout)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "already") {
return buserr.New(constant.ErrUserIsExist)
}
}
return nil
}
func (r *Remote) Delete(info DeleteInfo) error {
//暂时不支持强制删除,就算附加了 WITH(FORCE) 也会删除失败
err := r.ExecSQL(fmt.Sprintf(`DROP DATABASE "%s"`, info.Name), info.Timeout)
if err != nil {
return err
}
return r.ExecSQL(fmt.Sprintf(`DROP USER "%s"`, info.Username), info.Timeout)
}
func (r *Remote) ChangePassword(info PasswordChangeInfo) error {
return r.ExecSQL(fmt.Sprintf(`ALTER USER "%s" WITH ENCRYPTED PASSWORD '%s';`, info.Username, info.Password), info.Timeout)
}
func (r *Remote) ReloadConf()error {
return r.ExecSQL("SELECT pg_reload_conf();",5)
}
func (r *Remote) ChangeAccess(info AccessChangeInfo) error {
return nil
}
func (r *Remote) Backup(info BackupInfo) error {
fileOp := files.NewFileOp()
if !fileOp.Stat(info.TargetDir) {
if err := os.MkdirAll(info.TargetDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", info.TargetDir, err)
}
}
fileNameItem := info.TargetDir + "/" + strings.TrimSuffix(info.FileName, ".gz")
backupCommand := exec.Command("bash", "-c",
fmt.Sprintf("docker run --rm --net=host -i postgres:alpine /bin/bash -c 'PGPASSWORD=%s pg_dump -h %s -p %d --no-owner -Fc -U %s %s' > %s",
r.Password, r.Address, r.Port, r.User, info.Name, fileNameItem))
_ = backupCommand.Run()
b := make([]byte, 5)
n := []byte{80, 71, 68, 77, 80}
handle, err := os.OpenFile(fileNameItem, os.O_RDONLY, os.ModePerm)
if err != nil {
return fmt.Errorf("backup file not found,err:%v", err)
}
defer handle.Close()
_, _ = handle.Read(b)
if string(b) != string(n) {
errBytes, _ := os.ReadFile(fileNameItem)
return fmt.Errorf("backup failed,err:%s", string(errBytes))
}
gzipCmd := exec.Command("gzip", fileNameItem)
stdout, err := gzipCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("gzip file %s failed, stdout: %v, err: %v", strings.TrimSuffix(info.FileName, ".gz"), string(stdout), err)
}
return nil
}
func (r *Remote) Recover(info RecoverInfo) error {
fileName := info.SourceFile
if strings.HasSuffix(info.SourceFile, ".sql.gz") {
fileName = strings.TrimSuffix(info.SourceFile, ".gz")
gzipCmd := exec.Command("gunzip", info.SourceFile)
stdout, err := gzipCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("gunzip file %s failed, stdout: %v, err: %v", info.SourceFile, string(stdout), err)
}
defer func() {
gzipCmd := exec.Command("gzip", fileName)
_, _ = gzipCmd.CombinedOutput()
}()
}
recoverCommand := exec.Command("bash", "-c",
fmt.Sprintf("docker run --rm --net=host -i postgres:alpine /bin/bash -c 'PGPASSWORD=%s pg_restore -h %s -p %d --verbose --clean --no-privileges --no-owner -Fc -U %s -d %s --role=%s' < %s",
r.Password, r.Address, r.Port, r.User, info.Name, info.Username, fileName))
pipe, _ := recoverCommand.StdoutPipe()
stderrPipe, _ := recoverCommand.StderrPipe()
defer pipe.Close()
defer stderrPipe.Close()
if err := recoverCommand.Start(); err != nil {
return err
}
reader := bufio.NewReader(pipe)
for {
readString, err := reader.ReadString('\n')
if errors.Is(err, io.EOF) {
break
}
if err != nil {
all, _ := io.ReadAll(stderrPipe)
global.LOG.Errorf("[Postgresql] DB:[%s] Recover Error: %s", info.Name, string(all))
return err
}
global.LOG.Infof("[Postgresql] DB:[%s] Restoring: %s", info.Name, readString)
}
return nil
}
func (r *Remote) SyncDB(version string) ([]SyncDBInfo, error) {
//如果需要同步数据库,则需要强制修改用户密码,否则无法获取真实密码,后面可考虑改为添加服务器账号,手动将账号/数据库添加到管理列表
var datas []SyncDBInfo
return datas, nil
}
func (r *Remote) Close() {
_ = r.Client.Close()
}
func (r *Remote) ExecSQL(command string, timeout uint) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
if _, err := r.Client.ExecContext(ctx, command); err != nil {
return err
}
if ctx.Err() == context.DeadlineExceeded {
return buserr.New(constant.ErrExecTimeOut)
}
return nil
}

View File

@ -142,6 +142,7 @@ export namespace App {
}
export interface DatabaseConnInfo {
username: string;
password: string;
privilege: boolean;
serviceName: string;

View File

@ -48,6 +48,7 @@ export namespace Database {
permission: string;
description: string;
}
export interface BindUser {
database: string;
db: string;
@ -55,6 +56,7 @@ export namespace Database {
password: string;
permission: string;
}
export interface MysqlLoadDB {
from: string;
type: string;
@ -65,6 +67,7 @@ export namespace Database {
type: string;
database: string;
}
export interface MysqlDBDelete {
id: number;
type: string;
@ -145,6 +148,68 @@ export namespace Database {
database: string;
name: string;
}
export interface PostgresqlDBDelete {
id: number;
type: string;
database: string;
forceDelete: boolean;
deleteBackup: boolean;
}
export interface PostgresqlStatus {
uptime: string;
version: string;
max_connections: string;
autovacuum: string;
current_connections: string;
hit_ratio: string;
shared_buffers: string;
buffers_clean: string;
maxwritten_clean: string;
buffers_backend_fsync: string;
}
export interface PostgresqlDBDeleteCheck {
id: number;
type: string;
database: string;
}
export interface PostgresqlDBInfo {
id: number;
createdAt: Date;
name: string;
postgresqlName: string;
from: string;
format: string;
username: string;
password: string;
description: string;
}
export interface PostgresqlConfUpdateByFile {
type: string;
database: string;
file: string;
}
export interface PostgresqlDBCreate {
name: string;
from: string;
database: string;
format: string;
username: string;
password: string;
permission: string;
description: string;
}
export interface PostgresqlDBInfo {
id: number;
createdAt: Date;
name: string;
mysqlName: string;
from: string;
format: string;
username: string;
password: string;
permission: string;
description: string;
}
export interface ChangeInfo {
id: number;
from: string;

View File

@ -8,10 +8,42 @@ import { TimeoutEnum } from '@/enums/http-enum';
export const searchMysqlDBs = (params: Database.SearchDBWithPage) => {
return http.post<ResPage<Database.MysqlDBInfo>>(`/databases/search`, params);
};
export const loadDatabaseFile = (type: string, database: string) => {
return http.post<string>(`/databases/load/file`, { type: type, name: database });
};
export const addPostgresqlDB = (params: Database.PostgresqlDBCreate) => {
let request = deepCopy(params) as Database.PostgresqlDBCreate;
if (request.password) {
request.password = Base64.encode(request.password);
}
return http.post(`/databases/pg`, request);
};
export const loadPostgresqlStatus = (type: string, database: string) => {
return http.post<Database.PostgresqlStatus>(`/databases/pg/status`, { type: type, name: database });
};
export const updatePostgresqlConfByFile = (params: Database.PostgresqlConfUpdateByFile) => {
return http.post(`/databases/pg/conf`, params);
};
export const searchPostgresqlDBs = (params: Database.SearchDBWithPage) => {
return http.post<ResPage<Database.PostgresqlDBInfo>>(`/databases/pg/search`, params);
};
export const updatePostgresqlDescription = (params: DescriptionUpdate) => {
return http.post(`/databases/pg/description`, params);
};
export const deleteCheckPostgresqlDB = (params: Database.PostgresqlDBDeleteCheck) => {
return http.post<Array<string>>(`/databases/pg/del/check`, params);
};
export const updatePostgresqlPassword = (params: Database.ChangeInfo) => {
let request = deepCopy(params) as Database.ChangeInfo;
if (request.value) {
request.value = Base64.encode(request.value);
}
return http.post(`/databases/pg/password`, request);
};
export const deletePostgresqlDB = (params: Database.PostgresqlDBDelete) => {
return http.post(`/databases/pg/del`, params);
};
export const addMysqlDB = (params: Database.MysqlDBCreate) => {
let request = deepCopy(params) as Database.MysqlDBCreate;
if (request.password) {

View File

@ -201,6 +201,8 @@ const getTitle = (key: string) => {
return i18n.global.t('website.website');
case 'mysql':
return 'MySQL ' + i18n.global.t('menu.database');
case 'postgresql':
return 'PostgreSQL ' + i18n.global.t('menu.database');
case 'redis':
return 'Redis ' + i18n.global.t('menu.database');
}

View File

@ -54,7 +54,9 @@ const handleChange = (label: string) => {
onMounted(() => {
if (buttonArray.value.length) {
let isPathExist = false;
const btn = buttonArray.value.find((btn) => btn.path === router.currentRoute.value.path);
const btn = buttonArray.value.find((btn) => {
return router.currentRoute.value.path.startsWith(btn.path);
});
if (btn) {
isPathExist = true;
activeName.value = btn.label;

View File

@ -352,6 +352,7 @@ const message = {
deleteHelper: '" to delete this database',
create: 'Create database',
noMysql: 'Database service (MySQL or MariaDB)',
noPostgresql: 'Database service Postgresql',
goUpgrade: 'Go for upgrade',
goInstall: 'Go for install',
source: 'Source',

View File

@ -349,6 +349,7 @@ const message = {
deleteHelper: '" 刪除此數據庫',
create: '創建數據庫',
noMysql: '數據庫服務 (MySQL MariaDB)',
noPostgresql: '數據庫服務 Postgresql',
goUpgrade: '去應用商店升級',
goInstall: '去應用商店安裝',
source: '來源',

View File

@ -349,6 +349,7 @@ const message = {
deleteHelper: '" 删除此数据库',
create: '创建数据库',
noMysql: '数据库服务 (MySQL MariaDB)',
noPostgresql: '数据库服务 Postgresql',
goUpgrade: '去应用列表升级',
goInstall: '去应用商店安装',
source: '来源',

View File

@ -48,6 +48,37 @@ const databaseRouter = {
requiresAuth: false,
},
},
{
path: 'postgresql',
name: 'PostgreSQL',
component: () => import('@/views/database/postgresql/index.vue'),
hidden: true,
meta: {
activeMenu: '/databases',
requiresAuth: false,
},
},
{
path: 'postgresql/remote',
name: 'PostgreSQL-Remote',
component: () => import('@/views/database/postgresql/remote/index.vue'),
hidden: true,
meta: {
activeMenu: '/databases',
requiresAuth: false,
},
},
{
path: 'postgresql/setting/:type/:database',
name: 'PostgreSQL-Setting',
component: () => import('@/views/database/postgresql/setting/index.vue'),
props: true,
hidden: true,
meta: {
activeMenu: '/databases',
requiresAuth: false,
},
},
{
path: 'redis',
name: 'Redis',

View File

@ -13,6 +13,10 @@ const buttons = [
label: 'MySQL',
path: '/databases/mysql',
},
{
label: 'PostgreSQL',
path: '/databases/postgresql',
},
{
label: 'Redis',
path: '/databases/redis',

View File

@ -0,0 +1,54 @@
<template>
<el-dialog
v-model="open"
:title="$t('app.checkTitle')"
width="50%"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-row>
<el-col :span="20" :offset="2" v-if="open">
<el-alert
type="error"
:description="$t('app.deleteHelper', [$t('app.database')])"
center
show-icon
:closable="false"
/>
<br />
<el-descriptions border :column="1">
<el-descriptions-item>
<template #label>
<a href="javascript:void(0);" @click="toApp()">{{ $t('app.app') }}</a>
</template>
{{ installData.join(',') }}
</el-descriptions-item>
</el-descriptions>
</el-col>
</el-row>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
interface InstallProps {
items: Array<string>;
}
const installData = ref();
let open = ref(false);
const acceptParams = (props: InstallProps) => {
installData.value = props.items;
open.value = true;
};
const toApp = () => {
router.push({ name: 'AppInstalled' });
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,222 @@
<template>
<el-drawer v-model="dialogVisible" :destroy-on-close="true" :close-on-click-modal="false" size="30%">
<template #header>
<DrawerHeader :header="$t('database.databaseConnInfo')" :back="handleClose" />
</template>
<el-form @submit.prevent v-loading="loading" ref="formRef" :model="form" label-position="top">
<el-row type="flex" justify="center" v-if="form.from === 'local'">
<el-col :span="22">
<el-form-item :label="$t('database.containerConn')">
<el-tag>
{{ form.serviceName + form.port }}
</el-tag>
<CopyButton :content="form.serviceName + form.port" type="icon" />
<span class="input-help">
{{ $t('database.containerConnHelper') }}
</span>
</el-form-item>
<el-form-item :label="$t('database.remoteConn')">
<el-tooltip v-if="loadConnInfo(true).length > 48" :content="loadConnInfo(true)" placement="top">
<el-tag>{{ loadConnInfo(true).substring(0, 48) }}...</el-tag>
</el-tooltip>
<el-tag v-else>{{ loadConnInfo(true) }}</el-tag>
<CopyButton :content="form.systemIP + ':' + form.port" type="icon" />
<span class="input-help">{{ $t('database.remoteConnHelper2') }}</span>
</el-form-item>
<el-divider border-style="dashed" />
<el-form-item :label="$t('commons.login.username')" prop="username">
<el-input type="text" readonly disabled v-model="form.username">
<template #append>
<el-button-group>
<CopyButton :content="form.username" />
</el-button-group>
</template>
</el-input>
</el-form-item>
<el-form-item :label="$t('commons.login.password')" :rules="Rules.paramComplexity" prop="password">
<el-input type="password" show-password clearable v-model="form.password">
<template #append>
<el-button-group>
<CopyButton :content="form.password" />
<el-button @click="random">
{{ $t('commons.button.random') }}
</el-button>
</el-button-group>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row type="flex" justify="center" v-if="form.from !== 'local'">
<el-col :span="22">
<el-form-item :label="$t('database.remoteConn')">
<el-tooltip
v-if="loadConnInfo(false).length > 48"
:content="loadConnInfo(false)"
placement="top"
>
<el-tag>{{ loadConnInfo(false).substring(0, 48) }}...</el-tag>
</el-tooltip>
<el-tag v-else>{{ loadConnInfo(false) }}</el-tag>
<CopyButton :content="form.remoteIP + ':' + form.port" type="icon" />
</el-form-item>
<el-form-item :label="$t('commons.login.username')">
<el-tag>{{ form.username }}</el-tag>
<CopyButton :content="form.username" type="icon" />
</el-form-item>
<el-form-item :label="$t('commons.login.password')">
<el-tag>{{ form.password }}</el-tag>
<CopyButton :content="form.password" type="icon" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<ConfirmDialog ref="confirmDialogRef" @confirm="onSubmit" @cancel="loadPassword"></ConfirmDialog>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="dialogVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="onSave(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { getDatabase, updatePostgresqlPassword } from '@/api/modules/database';
import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import { GetAppConnInfo } from '@/api/modules/app';
import DrawerHeader from '@/components/drawer-header/index.vue';
import { MsgSuccess } from '@/utils/message';
import { getRandomStr } from '@/utils/util';
import { getSettingInfo } from '@/api/modules/setting';
const loading = ref(false);
const dialogVisible = ref(false);
const form = reactive({
systemIP: '',
password: '',
serviceName: '',
privilege: false,
port: 0,
from: '',
type: '',
database: '',
username: '',
remoteIP: '',
});
const confirmDialogRef = ref();
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
interface DialogProps {
from: string;
type: string;
database: string;
}
const acceptParams = (param: DialogProps): void => {
form.password = '';
form.from = param.from;
form.type = param.type;
form.database = param.database;
loadAccess();
loadPassword();
dialogVisible.value = true;
};
function loadConnInfo(isLocal: boolean) {
let ip = isLocal ? form.systemIP : form.remoteIP;
let info = ip + ':' + form.port;
return info;
}
const random = async () => {
form.password = getRandomStr(16);
};
const handleClose = () => {
dialogVisible.value = false;
};
const loadAccess = async () => {
if (form.from === 'local') {
// const res = await loadRemoteAccess(form.type, form.database);
form.privilege = false;
}
};
const loadSystemIP = async () => {
const res = await getSettingInfo();
form.systemIP = res.data.systemIP || i18n.global.t('database.localIP');
};
const loadPassword = async () => {
if (form.from === 'local') {
const res = await GetAppConnInfo(form.type, form.database);
form.username = res.data.username || '';
form.password = res.data.password || '';
form.port = res.data.port || 5432;
form.serviceName = res.data.serviceName || '';
loadSystemIP();
return;
}
const res = await getDatabase(form.database);
form.password = res.data.password || '';
form.port = res.data.port || 5432;
form.username = res.data.username;
form.password = res.data.password;
form.remoteIP = res.data.address;
};
const onSubmit = async () => {
let param = {
id: 0,
from: form.from,
type: form.type,
database: form.database,
value: form.password,
};
loading.value = true;
await updatePostgresqlPassword(param)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
dialogVisible.value = false;
})
.catch(() => {
loading.value = false;
});
};
const onSave = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
let params = {
header: i18n.global.t('database.confChange'),
operationInfo: i18n.global.t('database.restartNowHelper'),
submitInputInfo: i18n.global.t('database.restartNow'),
};
confirmDialogRef.value!.acceptParams(params);
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,131 @@
<template>
<el-drawer v-model="createVisible" :destroy-on-close="true" :close-on-click-modal="false" size="30%">
<template #header>
<DrawerHeader :header="$t('database.create')" :back="handleClose" />
</template>
<div v-loading="loading">
<el-form ref="formRef" label-position="top" :model="form" :rules="rules">
<el-row type="flex" justify="center">
<el-col :span="22">
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input clearable v-model.trim="form.name" @input="form.username = form.name"></el-input>
</el-form-item>
<el-form-item :label="$t('commons.login.username')" prop="username">
<el-input clearable v-model.trim="form.username" />
</el-form-item>
<el-form-item :label="$t('commons.login.password')" prop="password">
<el-input type="password" clearable show-password v-model.trim="form.password">
<template #append>
<el-button @click="random">{{ $t('commons.button.random') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item :label="$t('commons.table.type')" prop="database">
<el-tag>{{ form.database + ' [' + form.type + ']' }}</el-tag>
</el-form-item>
<el-form-item :label="$t('commons.table.description')" prop="description">
<el-input type="textarea" clearable v-model="form.description" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="createVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="onSubmit(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { addPostgresqlDB } from '@/api/modules/database';
import DrawerHeader from '@/components/drawer-header/index.vue';
import { MsgSuccess } from '@/utils/message';
import { getRandomStr } from '@/utils/util';
const loading = ref();
const createVisible = ref(false);
const form = reactive({
name: '',
from: 'local',
type: '',
database: '',
format: '',
username: '',
password: '',
permission: '',
permissionIPs: '',
description: '',
});
const rules = reactive({
name: [Rules.requiredInput, Rules.dbName],
username: [Rules.requiredInput, Rules.name],
password: [Rules.paramComplexity],
});
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
interface DialogProps {
from: string;
type: string;
database: string;
}
const acceptParams = (params: DialogProps): void => {
form.name = '';
form.from = params.from;
form.type = params.type;
form.database = params.database;
form.format = 'UTF8';
form.username = '';
form.permission = '%';
form.permissionIPs = '';
form.description = '';
random();
createVisible.value = true;
};
const handleClose = () => {
createVisible.value = false;
};
const random = async () => {
form.password = getRandomStr(16);
};
const emit = defineEmits<{ (e: 'search'): void }>();
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
await addPostgresqlDB(form)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('search');
createVisible.value = false;
})
.catch(() => {
loading.value = false;
});
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,95 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="$t('commons.button.delete') + ' - ' + dbName"
width="30%"
:close-on-click-modal="false"
>
<el-form ref="deleteForm" v-loading="loading" @submit.prevent>
<el-form-item>
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
<span class="input-help">
{{ $t('database.deleteBackupHelper') }}
</span>
</el-form-item>
<el-form-item>
<div>
<span style="font-size: 12px">{{ $t('database.delete') }}</span>
<span style="font-size: 12px; color: red; font-weight: 500">{{ dbName }}</span>
<span style="font-size: 12px">{{ $t('database.deleteHelper') }}</span>
</div>
<el-input v-model="deleteInfo" :placeholder="dbName"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false" :disabled="loading">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="submit" :disabled="deleteInfo != dbName || loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { FormInstance } from 'element-plus';
import { ref } from 'vue';
import i18n from '@/lang';
import { deletePostgresqlDB } from '@/api/modules/database';
import { MsgSuccess } from '@/utils/message';
let deleteReq = ref({
id: 0,
type: '',
database: '',
deleteBackup: false,
forceDelete: false,
});
let dialogVisible = ref(false);
let loading = ref(false);
let deleteInfo = ref('');
let dbName = ref('');
const deleteForm = ref<FormInstance>();
interface DialogProps {
id: number;
type: string;
name: string;
database: string;
}
const emit = defineEmits<{ (e: 'search'): void }>();
const acceptParams = async (prop: DialogProps) => {
deleteReq.value = {
id: prop.id,
type: prop.type,
database: prop.database,
deleteBackup: false,
forceDelete: false,
};
dbName.value = prop.name;
deleteInfo.value = '';
dialogVisible.value = true;
};
const submit = async () => {
loading.value = true;
deletePostgresqlDB(deleteReq.value)
.then(() => {
loading.value = false;
emit('search');
MsgSuccess(i18n.global.t('commons.msg.deleteSuccess'));
dialogVisible.value = false;
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,520 @@
<template>
<div v-loading="loading">
<div class="app-status" style="margin-top: 20px" v-if="currentDB?.from === 'remote'">
<el-card>
<div>
<el-tag style="float: left" effect="dark" type="success">PostgreSQL</el-tag>
<el-tag class="status-content">{{ $t('app.version') }}: {{ currentDB?.version }}</el-tag>
</div>
</el-card>
</div>
<LayoutContent :title="'PostgreSQL ' + $t('menu.database')">
<template #app v-if="currentDB?.from === 'local'">
<AppStatus
:app-key="appKey"
:app-name="appName"
v-model:loading="loading"
v-model:mask-show="maskShow"
@setting="onSetting"
@is-exist="checkExist"
></AppStatus>
</template>
<template #search v-if="currentDB">
<el-select v-model="currentDBName" @change="changeDatabase()">
<template #prefix>{{ $t('commons.table.type') }}</template>
<el-option-group :label="$t('database.local')">
<div v-for="(item, index) in dbOptionsLocal" :key="index">
<el-option v-if="item.from === 'local'" :value="item.database" class="optionClass">
<span v-if="item.database.length < 25">{{ item.database }}</span>
<el-tooltip v-else :content="item.database" placement="top">
<span>{{ item.database.substring(0, 25) }}...</span>
</el-tooltip>
</el-option>
</div>
<el-button link type="primary" class="jumpAdd" @click="goRouter('app')" icon="Position">
{{ $t('database.goInstall') }}
</el-button>
</el-option-group>
<el-option-group :label="$t('database.remote')">
<div v-for="(item, index) in dbOptionsRemote" :key="index">
<el-option v-if="item.from === 'remote'" :value="item.database" class="optionClass">
<span v-if="item.database.length < 25">{{ item.database }}</span>
<el-tooltip v-else :content="item.database" placement="top">
<span>{{ item.database.substring(0, 25) }}...</span>
</el-tooltip>
</el-option>
</div>
<el-button link type="primary" class="jumpAdd" @click="goRouter('remote')" icon="Position">
{{ $t('database.createRemoteDB') }}
</el-button>
</el-option-group>
</el-select>
</template>
<template #toolbar>
<el-row>
<el-col :xs="24" :sm="20" :md="20" :lg="20" :xl="20">
<el-button
v-if="currentDB && (currentDB.from !== 'local' || postgresqlStatus === 'Running')"
type="primary"
@click="onOpenDialog()"
>
{{ $t('database.create') }}
</el-button>
<el-button
v-if="currentDB && (currentDB.from !== 'local' || postgresqlStatus === 'Running')"
@click="onChangeConn"
type="primary"
plain
>
{{ $t('database.databaseConnInfo') }}
</el-button>
<el-button @click="goRemoteDB" type="primary" plain>
{{ $t('database.remoteDB') }}
</el-button>
<el-button @click="goDashboard()" type="primary" plain>PGAdmin4</el-button>
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4">
<div class="search-button">
<el-input
v-model="searchName"
clearable
@clear="search()"
suffix-icon="Search"
@keyup.enter="search()"
@change="search()"
:placeholder="$t('commons.button.search')"
></el-input>
</div>
</el-col>
</el-row>
</template>
<template #main v-if="currentDB">
<ComplexTable :pagination-config="paginationConfig" @sort-change="search" @search="search" :data="data">
<el-table-column :label="$t('commons.table.name')" prop="name" sortable />
<el-table-column :label="$t('commons.login.username')" prop="username" />
<el-table-column :label="$t('commons.login.password')" prop="password">
<template #default="{ row }">
<div class="flex items-center" v-if="row.password">
<div class="star-center" v-if="!row.showPassword">
<span>**********</span>
</div>
<div>
<span v-if="row.showPassword">
{{ row.password }}
</span>
</div>
<el-button
v-if="!row.showPassword"
link
@click="row.showPassword = true"
icon="View"
class="ml-1.5"
></el-button>
<el-button
v-if="row.showPassword"
link
@click="row.showPassword = false"
icon="Hide"
class="ml-1.5"
></el-button>
<div>
<CopyButton :content="row.password" type="icon" />
</div>
</div>
<div v-else>
<el-link @click="onChangePassword(row)">
<span style="font-size: 12px">{{ $t('database.passwordHelper') }}</span>
</el-link>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.description')" prop="description">
<template #default="{ row }">
<fu-input-rw-switch v-model="row.description" @blur="onChange(row)" />
</template>
</el-table-column>
<el-table-column
prop="createdAt"
:label="$t('commons.table.date')"
:formatter="dateFormat"
show-overflow-tooltip
/>
<fu-table-operations
width="370px"
:buttons="buttons"
:ellipsis="10"
:label="$t('commons.table.operate')"
fix
/>
</ComplexTable>
</template>
</LayoutContent>
<div v-if="dbOptionsLocal.length === 0 && dbOptionsRemote.length === 0">
<LayoutContent :title="'PostgreSQL ' + $t('menu.database')" :divider="true">
<template #main>
<div class="app-warn">
<div>
<span>{{ $t('app.checkInstalledWarn', [$t('database.noPostgresql')]) }}</span>
<span @click="goRouter('app')">
<el-icon class="ml-2"><Position /></el-icon>
{{ $t('database.goInstall') }}
</span>
<div>
<img src="@/assets/images/no_app.svg" />
</div>
</div>
</div>
</template>
</LayoutContent>
</div>
<el-dialog
v-model="dashboardVisible"
:title="$t('app.checkTitle')"
width="30%"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-alert :closable="false" :title="$t('app.checkInstalledWarn', [dashboardName])" type="info">
<el-link icon="Position" @click="getAppDetail" type="primary">
{{ $t('database.goInstall') }}
</el-link>
</el-alert>
<template #footer>
<span class="dialog-footer">
<el-button @click="dashboardVisible = false">{{ $t('commons.button.cancel') }}</el-button>
</span>
</template>
</el-dialog>
<PasswordDialog ref="passwordRef" @search="search" />
<RootPasswordDialog ref="connRef" />
<UploadDialog ref="uploadRef" />
<OperateDialog @search="search" ref="dialogRef" />
<Backups ref="dialogBackupRef" />
<AppResources ref="checkRef"></AppResources>
<DeleteDialog ref="deleteRef" @search="search" />
<PortJumpDialog ref="dialogPortJumpRef" />
</div>
</template>
<script lang="ts" setup>
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 RootPasswordDialog from '@/views/database/postgresql/conn/index.vue';
import AppResources from '@/views/database/postgresql/check/index.vue';
import AppStatus from '@/components/app-status/index.vue';
import Backups from '@/components/backup/index.vue';
import UploadDialog from '@/components/upload/index.vue';
import PortJumpDialog from '@/components/port-jump/index.vue';
import { dateFormat } from '@/utils/util';
import { onMounted, reactive, ref } from 'vue';
import {
deleteCheckPostgresqlDB,
listDatabases,
searchPostgresqlDBs,
updatePostgresqlDescription,
} from '@/api/modules/database';
import i18n from '@/lang';
import { Database } from '@/api/interface/database';
import { App } from '@/api/interface/app';
import { GetAppPort } from '@/api/modules/app';
import router from '@/routers';
import { MsgSuccess } from '@/utils/message';
import { GlobalStore } from '@/store';
const globalStore = GlobalStore();
const loading = ref(false);
const maskShow = ref(true);
const appKey = ref('postgresql');
const appName = ref();
const dbOptionsLocal = ref<Array<Database.DatabaseOption>>([]);
const dbOptionsRemote = ref<Array<Database.DatabaseOption>>([]);
const currentDB = ref<Database.DatabaseOption>();
const currentDBName = ref();
const checkRef = ref();
const deleteRef = ref();
const pgadminPort = ref();
const dashboardName = ref();
const dashboardKey = ref();
const dashboardVisible = ref(false);
const dialogPortJumpRef = ref();
const data = ref();
const paginationConfig = reactive({
cacheSizeKey: 'postgresql-page-size',
currentPage: 1,
pageSize: Number(localStorage.getItem('postgresql-page-size')) || 10,
total: 0,
orderBy: 'created_at',
order: 'null',
});
const searchName = ref();
const postgresqlContainer = ref();
const postgresqlStatus = ref();
const postgresqlVersion = ref();
const dialogRef = ref();
const onOpenDialog = async () => {
let params = {
from: currentDB.value.from,
type: currentDB.value.type,
database: currentDBName.value,
};
dialogRef.value!.acceptParams(params);
};
const dialogBackupRef = ref();
const uploadRef = ref();
const connRef = ref();
const onChangeConn = async () => {
connRef.value!.acceptParams({
from: currentDB.value.from,
type: currentDB.value.type,
database: currentDBName.value,
});
};
const goRemoteDB = async () => {
if (currentDB.value) {
globalStore.setCurrentDB(currentDB.value.database);
}
router.push({ name: 'PostgreSQL-Remote' });
};
const passwordRef = ref();
const onSetting = async () => {
if (currentDB.value) {
globalStore.setCurrentDB(currentDB.value.database);
}
router.push({
name: 'PostgreSQL-Setting',
params: { type: currentDB.value.type, database: currentDB.value.database },
});
};
const changeDatabase = async () => {
for (const item of dbOptionsLocal.value) {
if (item.database == currentDBName.value) {
currentDB.value = item;
appKey.value = item.type;
appName.value = item.database;
search();
return;
}
}
for (const item of dbOptionsRemote.value) {
if (item.database == currentDBName.value) {
currentDB.value = item;
break;
}
}
search();
};
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
paginationConfig.order = column?.order ? column.order : paginationConfig.order;
let params = {
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
info: searchName.value,
database: currentDB.value.database,
orderBy: paginationConfig.orderBy,
order: paginationConfig.order,
};
const res = await searchPostgresqlDBs(params);
data.value = res.data.items || [];
paginationConfig.total = res.data.total;
};
const goRouter = async (target: string) => {
if (target === 'app') {
router.push({ name: 'AppAll', query: { install: 'postgresql' } });
return;
}
router.push({ name: 'PostgreSQL-Remote' });
};
const onChange = async (info: any) => {
await updatePostgresqlDescription({ id: info.id, description: info.description });
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
};
const goDashboard = async () => {
if (pgadminPort.value === 0) {
dashboardName.value = 'PGAdmin4';
dashboardKey.value = 'pgadmin4';
dashboardVisible.value = true;
return;
}
dialogPortJumpRef.value.acceptParams({ port: pgadminPort.value });
return;
};
const getAppDetail = () => {
router.push({ name: 'AppAll', query: { install: dashboardKey.value } });
};
const loadPGAdminPort = async () => {
const res = await GetAppPort('pgadmin4', '');
pgadminPort.value = res.data;
};
const checkExist = (data: App.CheckInstalled) => {
postgresqlStatus.value = data.status;
postgresqlVersion.value = data.version;
postgresqlContainer.value = data.containerName;
};
const loadDBOptions = async () => {
const res = await listDatabases('postgresql');
let datas = res.data || [];
dbOptionsLocal.value = [];
dbOptionsRemote.value = [];
currentDBName.value = globalStore.currentDB;
for (const item of datas) {
if (currentDBName.value && item.database === currentDBName.value) {
currentDB.value = item;
if (item.from === 'local') {
appKey.value = item.type;
appName.value = item.database;
}
}
if (item.from === 'local') {
dbOptionsLocal.value.push(item);
} else {
dbOptionsRemote.value.push(item);
}
}
if (currentDB.value) {
globalStore.setCurrentDB('');
search();
return;
}
if (dbOptionsLocal.value.length !== 0) {
currentDB.value = dbOptionsLocal.value[0];
currentDBName.value = dbOptionsLocal.value[0].database;
appKey.value = dbOptionsLocal.value[0].type;
appName.value = dbOptionsLocal.value[0].database;
}
if (!currentDB.value && dbOptionsRemote.value.length !== 0) {
currentDB.value = dbOptionsRemote.value[0];
currentDBName.value = dbOptionsRemote.value[0].database;
}
if (currentDB.value) {
search();
}
};
const onDelete = async (row: Database.PostgresqlDBInfo) => {
let param = {
id: row.id,
type: currentDB.value.type,
database: currentDBName.value,
};
const res = await deleteCheckPostgresqlDB(param);
if (res.data && res.data.length > 0) {
checkRef.value.acceptParams({ items: res.data });
} else {
deleteRef.value.acceptParams({
id: row.id,
type: currentDB.value.type,
database: currentDBName.value,
name: row.name,
});
}
};
const onChangePassword = async (row: Database.PostgresqlDBInfo) => {
let param = {
id: row.id,
from: row.from,
type: currentDB.value.type,
database: currentDBName.value,
postgresqlName: row.name,
operation: 'password',
username: row.username,
password: row.password,
};
passwordRef.value.acceptParams(param);
};
const buttons = [
{
label: i18n.global.t('database.changePassword'),
click: (row: Database.PostgresqlDBInfo) => {
onChangePassword(row);
},
},
{
label: i18n.global.t('database.backupList'),
click: (row: Database.PostgresqlDBInfo) => {
let params = {
type: currentDB.value.type,
name: currentDBName.value,
detailName: row.name,
};
dialogBackupRef.value!.acceptParams(params);
},
},
{
label: i18n.global.t('database.loadBackup'),
click: (row: Database.PostgresqlDBInfo) => {
let params = {
type: currentDB.value.type,
name: currentDBName.value,
detailName: row.name,
remark: row.format,
};
uploadRef.value!.acceptParams(params);
},
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: Database.PostgresqlDBInfo) => {
onDelete(row);
},
},
];
onMounted(() => {
loadDBOptions();
loadPGAdminPort();
});
</script>
<style lang="scss" scoped>
.iconInTable {
margin-left: 5px;
margin-top: 3px;
}
.jumpAdd {
margin-top: 10px;
margin-left: 15px;
margin-bottom: 5px;
font-size: 12px;
}
.tagClass {
float: right;
font-size: 12px;
margin-top: 5px;
}
.optionClass {
min-width: 350px;
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<div>
<el-drawer v-model="changeVisible" :destroy-on-close="true" :close-on-click-modal="false" width="30%">
<template #header>
<DrawerHeader :header="title" :resource="changeForm.postgresqlName" :back="handleClose" />
</template>
<el-form v-loading="loading" ref="changeFormRef" :model="changeForm" label-position="top">
<el-row type="flex" justify="center">
<el-col :span="22">
<div v-if="changeForm.operation === 'password'">
<el-form-item :label="$t('commons.login.username')" prop="userName">
<el-input disabled v-model="changeForm.userName"></el-input>
</el-form-item>
<el-form-item :label="$t('commons.login.password')" prop="password">
<el-input
type="password"
clearable
show-password
v-model="changeForm.password"
></el-input>
</el-form-item>
</div>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="changeVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="submitChangeInfo(changeFormRef)">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-drawer>
<ConfirmDialog ref="confirmDialogRef" @confirm="onSubmit"></ConfirmDialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { deleteCheckPostgresqlDB, updatePostgresqlPassword } from '@/api/modules/database';
import DrawerHeader from '@/components/drawer-header/index.vue';
import { MsgSuccess } from '@/utils/message';
const loading = ref();
const changeVisible = ref(false);
type FormInstance = InstanceType<typeof ElForm>;
const changeFormRef = ref<FormInstance>();
const title = ref();
const changeForm = reactive({
id: 0,
from: '',
type: '',
database: '',
postgresqlName: '',
userName: '',
password: '',
operation: '',
value: '',
});
const confirmDialogRef = ref();
interface DialogProps {
id: number;
from: string;
type: string;
database: string;
postgresqlName: string;
username: string;
password: string;
operation: string;
privilege: string;
privilegeIPs: string;
value: string;
}
const acceptParams = (params: DialogProps): void => {
title.value = i18n.global.t('database.changePassword');
changeForm.id = params.id;
changeForm.from = params.from;
changeForm.type = params.type;
changeForm.database = params.database;
changeForm.postgresqlName = params.postgresqlName;
changeForm.userName = params.username;
changeForm.password = params.password;
changeForm.operation = params.operation;
changeForm.value = params.value;
changeVisible.value = true;
};
const emit = defineEmits<{ (e: 'search'): void }>();
const handleClose = () => {
changeVisible.value = false;
};
const submitChangeInfo = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
let param = {
id: changeForm.id,
from: changeForm.from,
type: changeForm.type,
database: changeForm.database,
value: '',
};
if (changeForm.operation === 'password') {
const res = await deleteCheckPostgresqlDB(param);
if (res.data && res.data.length > 0) {
let params = {
header: i18n.global.t('database.changePassword'),
operationInfo: i18n.global.t('database.changePasswordHelper'),
submitInputInfo: i18n.global.t('database.restartNow'),
};
confirmDialogRef.value!.acceptParams(params);
} else {
param.value = changeForm.password;
loading.value = true;
await updatePostgresqlPassword(param)
.then(() => {
loading.value = false;
emit('search');
changeVisible.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
}
return;
}
loading.value = true;
});
};
const onSubmit = async () => {
let param = {
id: changeForm.id,
from: changeForm.from,
type: changeForm.type,
database: changeForm.database,
value: changeForm.password,
};
loading.value = true;
await updatePostgresqlPassword(param)
.then(() => {
loading.value = false;
emit('search');
changeVisible.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,95 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="$t('commons.button.delete') + ' - ' + deleteReq.database"
width="30%"
:close-on-click-modal="false"
>
<el-form ref="deleteForm" v-loading="loading" @submit.prevent>
<el-form-item>
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('app.forceDelete')" />
<span class="input-help">
{{ $t('app.forceDeleteHelper') }}
</span>
</el-form-item>
<el-form-item>
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
<span class="input-help">
{{ $t('database.deleteBackupHelper') }}
</span>
</el-form-item>
<el-form-item>
<div>
<span style="font-size: 12px">{{ $t('database.delete') }}</span>
<span style="font-size: 12px; color: red; font-weight: 500">{{ deleteReq.database }}</span>
<span style="font-size: 12px">{{ $t('database.deleteHelper') }}</span>
</div>
<el-input v-model="deleteInfo" :placeholder="deleteReq.database"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false" :disabled="loading">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="submit" :disabled="deleteInfo != deleteReq.database || loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { FormInstance } from 'element-plus';
import { ref } from 'vue';
import i18n from '@/lang';
import { deleteDatabase } from '@/api/modules/database';
import { MsgSuccess } from '@/utils/message';
let deleteReq = ref({
id: 0,
database: '',
deleteBackup: false,
forceDelete: false,
});
let dialogVisible = ref(false);
let loading = ref(false);
let deleteInfo = ref('');
const deleteForm = ref<FormInstance>();
interface DialogProps {
id: number;
name: string;
database: string;
}
const emit = defineEmits<{ (e: 'search'): void }>();
const acceptParams = async (prop: DialogProps) => {
deleteReq.value = {
id: prop.id,
database: prop.database,
deleteBackup: false,
forceDelete: false,
};
dialogVisible.value = true;
};
const submit = async () => {
loading.value = true;
deleteDatabase(deleteReq.value)
.then(() => {
loading.value = false;
emit('search');
MsgSuccess(i18n.global.t('commons.msg.deleteSuccess'));
dialogVisible.value = false;
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,186 @@
<template>
<div v-loading="loading">
<LayoutContent>
<template #title>
<back-button name="PostgreSQL" :header="$t('database.remoteDB')" />
</template>
<template #toolbar>
<el-row>
<el-col :xs="24" :sm="20" :md="20" :lg="20" :xl="20">
<el-button type="primary" @click="onOpenDialog('create')">
{{ $t('database.createRemoteDB') }}
</el-button>
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4">
<div class="search-button">
<el-input
v-model="searchName"
clearable
@clear="search()"
suffix-icon="Search"
@keyup.enter="search()"
@change="search()"
:placeholder="$t('commons.button.search')"
></el-input>
</div>
</el-col>
</el-row>
</template>
<template #main>
<ComplexTable :pagination-config="paginationConfig" @sort-change="search" @search="search" :data="data">
<el-table-column show-overflow-tooltip :label="$t('commons.table.name')" prop="name" sortable />
<el-table-column show-overflow-tooltip :label="$t('database.address')" prop="address" />
<el-table-column :label="$t('commons.login.username')" prop="username" />
<el-table-column :label="$t('commons.login.password')" prop="password">
<template #default="{ row }">
<div class="flex items-center">
<div class="star-center">
<span v-if="!row.showPassword">**********</span>
</div>
<div>
<span v-if="row.showPassword">
{{ row.password }}
</span>
</div>
<el-button
v-if="!row.showPassword"
link
@click="row.showPassword = true"
icon="View"
class="ml-1.5"
></el-button>
<el-button
v-if="row.showPassword"
link
@click="row.showPassword = false"
icon="Hide"
class="ml-1.5"
></el-button>
<div>
<CopyButton :content="row.password" type="icon" />
</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="description"
:label="$t('commons.table.description')"
show-overflow-tooltip
/>
<el-table-column
prop="createdAt"
:label="$t('commons.table.date')"
:formatter="dateFormat"
show-overflow-tooltip
/>
<fu-table-operations
width="170px"
:buttons="buttons"
:ellipsis="10"
:label="$t('commons.table.operate')"
fix
/>
</ComplexTable>
</template>
</LayoutContent>
<AppResources ref="checkRef"></AppResources>
<OperateDialog ref="dialogRef" @search="search" />
<DeleteDialog ref="deleteRef" @search="search" />
</div>
</template>
<script lang="ts" setup>
import { dateFormat } from '@/utils/util';
import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
import AppResources from '@/views/database/postgresql/check/index.vue';
import OperateDialog from '@/views/database/postgresql/remote/operate/index.vue';
import DeleteDialog from '@/views/database/postgresql/remote/delete/index.vue';
import i18n from '@/lang';
import { Database } from '@/api/interface/database';
const loading = ref(false);
const dialogRef = ref();
const checkRef = ref();
const deleteRef = ref();
const data = ref();
const paginationConfig = reactive({
cacheSizeKey: 'postgresql-remote-page-size',
currentPage: 1,
pageSize: 10,
total: 0,
orderBy: 'created_at',
order: 'null',
});
const searchName = ref();
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
paginationConfig.order = column?.order ? column.order : paginationConfig.order;
let params = {
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
info: searchName.value,
type: 'postgresql',
orderBy: paginationConfig.orderBy,
order: paginationConfig.order,
};
const res = await searchDatabases(params);
data.value = res.data.items || [];
paginationConfig.total = res.data.total;
};
const onOpenDialog = async (
title: string,
rowData: Partial<Database.DatabaseInfo> = {
name: '',
type: 'postgresql',
version: '16.x',
address: '',
port: 5432,
username: 'postgres',
password: '',
description: '',
},
) => {
let params = {
title,
rowData: { ...rowData },
};
dialogRef.value!.acceptParams(params);
};
const onDelete = async (row: Database.DatabaseInfo) => {
const res = await deleteCheckDatabase(row.id);
if (res.data && res.data.length > 0) {
checkRef.value.acceptParams({ items: res.data });
} else {
deleteRef.value.acceptParams({
id: row.id,
database: row.name,
});
}
};
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
click: (row: Database.DatabaseInfo) => {
onOpenDialog('edit', row);
},
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: Database.DatabaseInfo) => {
onDelete(row);
},
},
];
onMounted(() => {
search();
});
</script>

View File

@ -0,0 +1,175 @@
<template>
<el-drawer v-model="drawerVisible" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
<template #header>
<DrawerHeader
:hideResource="dialogData.title === 'create'"
:header="title"
:resource="dialogData.rowData?.name"
:back="handleClose"
/>
</template>
<el-form ref="formRef" v-loading="loading" label-position="top" :model="dialogData.rowData" :rules="rules">
<el-row type="flex" justify="center">
<el-col :span="22">
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input
v-if="dialogData.title === 'create'"
clearable
v-model.trim="dialogData.rowData!.name"
/>
<el-tag v-else>{{ dialogData.rowData!.name }}</el-tag>
</el-form-item>
<el-form-item :label="$t('database.version')" prop="version">
<el-radio-group v-model="dialogData.rowData!.version" @change="isOK = false">
<el-radio label="16.x" />
<el-radio label="15.x" />
<el-radio label="14.x" />
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('database.address')" prop="address">
<el-input @change="isOK = false" clearable v-model.trim="dialogData.rowData!.address" />
</el-form-item>
<el-form-item :label="$t('commons.table.port')" prop="port">
<el-input @change="isOK = false" clearable v-model.number="dialogData.rowData!.port" />
</el-form-item>
<el-form-item :label="$t('commons.login.username')" prop="username">
<el-input @change="isOK = false" clearable v-model.trim="dialogData.rowData!.username" />
<span class="input-help">{{ $t('database.userHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('commons.login.password')" prop="password">
<el-input
@change="isOK = false"
type="password"
clearable
show-password
v-model.trim="dialogData.rowData!.password"
/>
</el-form-item>
<el-form-item :label="$t('commons.table.description')" prop="description">
<el-input clearable v-model.trim="dialogData.rowData!.description" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button @click="onSubmit(formRef, 'check')">
{{ $t('terminal.testConn') }}
</el-button>
<el-button type="primary" :disabled="!isOK" @click="onSubmit(formRef, dialogData.title)">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { Database } from '@/api/interface/database';
import DrawerHeader from '@/components/drawer-header/index.vue';
import { MsgError, MsgSuccess } from '@/utils/message';
import { Rules } from '@/global/form-rules';
import { addDatabase, checkDatabase, editDatabase } from '@/api/modules/database';
interface DialogProps {
title: string;
rowData?: Database.DatabaseInfo;
getTableList?: () => Promise<any>;
}
const title = ref<string>('');
const drawerVisible = ref(false);
const dialogData = ref<DialogProps>({
title: '',
});
const isOK = ref(false);
const loading = ref();
const acceptParams = (params: DialogProps): void => {
dialogData.value = params;
dialogData.value.rowData.hasCA = dialogData.value.rowData.rootCert?.length !== 0;
title.value = i18n.global.t('database.' + dialogData.value.title + 'RemoteDB');
drawerVisible.value = true;
};
const emit = defineEmits<{ (e: 'search'): void }>();
const handleClose = () => {
drawerVisible.value = false;
};
const rules = reactive({
name: [Rules.requiredInput],
type: [Rules.requiredSelect],
version: [Rules.requiredSelect],
address: [Rules.ipV4V6OrDomain],
port: [Rules.port],
username: [Rules.requiredInput],
password: [Rules.requiredInput],
clientKey: [Rules.requiredInput],
clientCert: [Rules.requiredInput],
rootCert: [Rules.requiredInput],
});
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
const onSubmit = async (formEl: FormInstance | undefined, operation: string) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
dialogData.value.rowData.from = 'remote';
loading.value = true;
dialogData.value.rowData.rootCert = dialogData.value.rowData.hasCA ? dialogData.value.rowData.rootCert : '';
if (operation === 'check') {
await checkDatabase(dialogData.value.rowData)
.then((res) => {
loading.value = false;
if (res.data) {
isOK.value = true;
MsgSuccess(i18n.global.t('terminal.connTestOk'));
} else {
MsgError(i18n.global.t('terminal.connTestFailed'));
}
})
.catch(() => {
loading.value = false;
MsgError(i18n.global.t('terminal.connTestFailed'));
});
}
if (operation === 'create') {
await addDatabase(dialogData.value.rowData)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('search');
drawerVisible.value = false;
})
.catch(() => {
loading.value = false;
});
}
if (operation === 'edit') {
await editDatabase(dialogData.value.rowData)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('search');
drawerVisible.value = false;
})
.catch(() => {
loading.value = false;
});
}
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -0,0 +1,281 @@
<template>
<div v-loading="loading">
<LayoutContent>
<template #title>
<back-button name="PostgreSQL" :header="props.database + ' ' + $t('commons.button.set')">
<template #buttons>
<el-button type="primary" :plain="activeName !== 'conf'" @click="jumpToConf">
{{ $t('database.confChange') }}
</el-button>
<el-button
type="primary"
:disabled="postgresqlStatus !== 'Running'"
:plain="activeName !== 'status'"
@click="activeName = 'status'"
>
{{ $t('database.currentStatus') }}
</el-button>
<el-button type="primary" :plain="activeName !== 'port'" @click="activeName = 'port'">
{{ $t('commons.table.port') }}
</el-button>
<el-button
type="primary"
:disabled="postgresqlStatus !== 'Running'"
:plain="activeName !== 'log'"
@click="activeName = 'log'"
>
{{ $t('database.log') }}
</el-button>
</template>
</back-button>
</template>
<template #app>
<AppStatus :app-key="props.type" :app-name="props.database" v-model:loading="loading" />
</template>
<template #main>
<div v-if="activeName === 'conf'">
<codemirror
:autofocus="true"
:placeholder="$t('commons.msg.noneData')"
:indent-with-tab="true"
:tabSize="8"
style="margin-top: 10px; height: calc(100vh - 375px)"
:lineWrapping="true"
:matchBrackets="true"
theme="cobalt"
:styleActiveLine="true"
:extensions="extensions"
v-model="postgresqlConf"
/>
<el-button type="primary" style="margin-top: 10px" @click="onSaveConf">
{{ $t('commons.button.save') }}
</el-button>
<el-row>
<el-col :span="8">
<el-alert
v-if="useOld"
style="margin-top: 10px"
:title="$t('app.defaultConfigHelper')"
type="info"
:closable="false"
></el-alert>
</el-col>
</el-row>
</div>
<Status v-show="activeName === 'status'" ref="statusRef" />
<div v-show="activeName === 'port'">
<el-form :model="baseInfo" ref="panelFormRef" label-position="top">
<el-row>
<el-col :span="1"><br /></el-col>
<el-col :span="10">
<el-form-item :label="$t('commons.table.port')" prop="port" :rules="Rules.port">
<el-input clearable type="number" v-model.number="baseInfo.port" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSavePort(panelFormRef)" icon="Collection">
{{ $t('commons.button.save') }}
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<ContainerLog v-show="activeName === 'log'" ref="dialogContainerLogRef" />
</template>
</LayoutContent>
<el-dialog
v-model="upgradeVisible"
:title="$t('app.checkTitle')"
width="30%"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-alert :closable="false" :title="$t('database.confNotFound')" type="info">
<el-link icon="Position" @click="goUpgrade()" type="primary">
{{ $t('database.goUpgrade') }}
</el-link>
</el-alert>
<template #footer>
<span class="dialog-footer">
<el-button @click="upgradeVisible = false">{{ $t('commons.button.cancel') }}</el-button>
</span>
</template>
</el-dialog>
<ConfirmDialog ref="confirmPortRef" @confirm="onSubmitChangePort"></ConfirmDialog>
<ConfirmDialog ref="confirmConfRef" @confirm="onSubmitChangeConf"></ConfirmDialog>
</div>
</template>
<script lang="ts" setup>
import { FormInstance } from 'element-plus';
import ContainerLog from '@/components/container-log/index.vue';
import Status from '@/views/database/postgresql/setting/status/index.vue';
import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import { onMounted, reactive, ref } from 'vue';
import { Codemirror } from 'vue-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { loadDatabaseFile, loadMysqlBaseInfo, updatePostgresqlConfByFile } from '@/api/modules/database';
import { ChangePort, CheckAppInstalled } from '@/api/modules/app';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import router from '@/routers';
const loading = ref(false);
const extensions = [javascript(), oneDark];
const activeName = ref('conf');
const baseInfo = reactive({
name: '',
port: 5432,
password: '',
remoteConn: false,
containerID: '',
});
const panelFormRef = ref<FormInstance>();
const postgresqlConf = ref();
const upgradeVisible = ref();
const useOld = ref(false);
const statusRef = ref();
const postgresqlName = ref();
const postgresqlStatus = ref();
const postgresqlVersion = ref();
interface DBProps {
type: string;
database: string;
}
const props = withDefaults(defineProps<DBProps>(), {
type: '',
database: '',
});
const dialogContainerLogRef = ref();
const jumpToConf = async () => {
activeName.value = 'conf';
loadPostgresqlConf();
};
const onSubmitChangePort = async () => {
let params = {
key: props.type,
name: props.database,
port: baseInfo.port,
};
loading.value = true;
await ChangePort(params)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
};
const confirmPortRef = ref();
const onSavePort = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
const result = await formEl.validateField('port', callback);
if (!result) {
return;
}
let params = {
header: i18n.global.t('database.confChange'),
operationInfo: i18n.global.t('database.restartNowHelper'),
submitInputInfo: i18n.global.t('database.restartNow'),
};
confirmPortRef.value!.acceptParams(params);
return;
};
function callback(error: any) {
if (error) {
return error.message;
} else {
return;
}
}
const onSubmitChangeConf = async () => {
let param = {
type: props.type,
database: props.database,
file: postgresqlConf.value,
};
loading.value = true;
await updatePostgresqlConfByFile(param)
.then(() => {
useOld.value = false;
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
};
const confirmConfRef = ref();
const onSaveConf = async () => {
let params = {
header: i18n.global.t('database.confChange'),
operationInfo: i18n.global.t('database.restartNowHelper'),
submitInputInfo: i18n.global.t('database.restartNow'),
};
confirmConfRef.value!.acceptParams(params);
return;
};
const loadContainerLog = async (containerID: string) => {
dialogContainerLogRef.value!.acceptParams({ containerID: containerID, container: containerID });
};
const loadBaseInfo = async () => {
const res = await loadMysqlBaseInfo(props.type, props.database);
postgresqlName.value = res.data?.name;
baseInfo.port = res.data?.port;
baseInfo.containerID = res.data?.containerName;
loadPostgresqlConf();
loadContainerLog(baseInfo.containerID);
};
const loadPostgresqlConf = async () => {
useOld.value = false;
await loadDatabaseFile(props.type + '-conf', props.database)
.then((res) => {
loading.value = false;
postgresqlConf.value = res.data;
})
.catch(() => {
upgradeVisible.value = true;
loading.value = false;
});
};
const goUpgrade = () => {
router.push({ name: 'AppUpgrade' });
};
const onLoadInfo = async () => {
await CheckAppInstalled(props.type, props.database).then((res) => {
postgresqlName.value = res.data.name;
postgresqlStatus.value = res.data.status;
postgresqlVersion.value = res.data.version;
loadBaseInfo();
if (postgresqlStatus.value === 'Running') {
statusRef.value!.acceptParams({ type: props.type, database: props.database });
}
});
};
onMounted(() => {
onLoadInfo();
});
</script>

View File

@ -0,0 +1,168 @@
<template>
<div>
<el-form label-position="top">
<span class="title">{{ $t('database.baseParam') }}</span>
<el-divider class="divider" />
<el-row type="flex" justify="center" style="margin-left: 50px" :gutter="20">
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">{{ $t('database.runTime') }}</span>
</template>
<span class="status-count">{{ postgresqlStatus.uptime }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">{{ $t('database.connections') }}</span>
</template>
<span class="status-count">{{ postgresqlStatus.max_connections }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">{{ $t('database.version') }}</span>
</template>
<span class="status-count">{{ postgresqlStatus.version }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">AUTOVACUUM</span>
</template>
<span class="status-count">{{ postgresqlStatus.autovacuum }}</span>
</el-form-item>
</el-col>
</el-row>
<span class="title">{{ $t('database.performanceParam') }}</span>
<el-divider class="divider" />
<el-row type="flex" style="margin-left: 50px" justify="center" :gutter="20">
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">{{ $t('database.connInfo') }}</span>
</template>
<span class="status-count">{{ postgresqlStatus.current_connections }}</span>
<span class="input-help">{{ $t('database.connInfoHelper') }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">{{ $t('database.cacheHit') }}</span>
</template>
<span class="status-count">{{ postgresqlStatus.hit_ratio }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item style="width: 25%"></el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item style="width: 25%"></el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">SHARED_BUFFERS</span>
</template>
<span class="status-count">{{ postgresqlStatus.shared_buffers }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">BUFFERS_CLEAN</span>
</template>
<span class="status-count">{{ postgresqlStatus.buffers_clean }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">MAXWRITTEN_CLEAN</span>
</template>
<span class="status-count">{{ postgresqlStatus.maxwritten_clean }}</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="6" :lg="6" :xl="6">
<el-form-item>
<template #label>
<span class="status-label">BUFFERS_BACKEND_FSYNC</span>
</template>
<span class="status-count">{{ postgresqlStatus.buffers_backend_fsync }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { loadPostgresqlStatus } from '@/api/modules/database';
import { reactive } from 'vue';
const postgresqlStatus = reactive({
uptime: '',
version: '',
max_connections: '',
autovacuum: '',
current_connections: '',
hit_ratio: '',
shared_buffers: '',
buffers_clean: '',
maxwritten_clean: '',
buffers_backend_fsync: '',
});
const currentDB = reactive({
type: '',
database: '',
});
interface DialogProps {
type: string;
database: string;
}
const acceptParams = (params: DialogProps): void => {
currentDB.type = params.type;
currentDB.database = params.database;
loadStatus();
};
const loadStatus = async () => {
const res = await loadPostgresqlStatus(currentDB.type, currentDB.database);
postgresqlStatus.uptime = res.data.uptime;
postgresqlStatus.version = res.data.version;
postgresqlStatus.max_connections = res.data.max_connections;
postgresqlStatus.autovacuum = res.data.autovacuum;
postgresqlStatus.current_connections = res.data.current_connections;
postgresqlStatus.hit_ratio = res.data.hit_ratio;
postgresqlStatus.shared_buffers = res.data.shared_buffers;
postgresqlStatus.buffers_clean = res.data.buffers_clean;
postgresqlStatus.maxwritten_clean = res.data.maxwritten_clean;
postgresqlStatus.buffers_backend_fsync = res.data.buffers_backend_fsync;
};
defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.divider {
display: block;
height: 1px;
width: 100%;
margin: 12px 0;
border-top: 1px var(--el-border-color) var(--el-border-style);
}
.title {
font-size: 20px;
font-weight: 500;
margin-left: 50px;
}
</style>

3
go.mod
View File

@ -143,6 +143,9 @@ require (
github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect

5
go.sum
View File

@ -465,10 +465,15 @@ github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki
github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jgiannuzzi/go-sqlite3 v1.14.17-0.20230719111531-6e53453ccbd3 h1:Iqr8ZWwijosAcawoD9IZ7cwj61WH50Rysm+RQ+ZIB4I=
github.com/jgiannuzzi/go-sqlite3 v1.14.17-0.20230719111531-6e53453ccbd3/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg=