mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 01:12:17 +08:00
feat: MySQL 添加进程/事务锁/TopSQL/表维护/binlog/复制运维功能
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+10
-10
@@ -78,7 +78,16 @@ func initAce() (*app.Ace, func(), error) {
|
||||
giteaApp := gitea.NewApp()
|
||||
grafanaApp := grafana.NewApp(locale)
|
||||
kafkaApp := kafka.NewApp(locale)
|
||||
mysqlApp := mysql.NewApp(locale, databaseServerRepo, settingRepo)
|
||||
logger, cleanup, err := bootstrap.NewLogger(config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slogLogger := bootstrap.NewSlog(logger)
|
||||
notifyChannelRepo := data.NewNotifyChannelRepo(db)
|
||||
notifyUsecase := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
|
||||
taskRunner := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
|
||||
taskRepo := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
|
||||
mysqlApp := mysql.NewApp(locale, databaseServerRepo, settingRepo, taskRepo)
|
||||
mariadbApp := mariadb.NewApp(mysqlApp)
|
||||
memcachedApp := memcached.NewApp(locale)
|
||||
minioApp := minio.NewApp()
|
||||
@@ -90,15 +99,6 @@ func initAce() (*app.Ace, func(), error) {
|
||||
pgadminApp := pgadmin.NewApp(config, locale, databaseServerRepo)
|
||||
phpmyadminApp := phpmyadmin.NewApp(config, locale, databaseServerRepo)
|
||||
podmanApp := podman.NewApp()
|
||||
logger, cleanup, err := bootstrap.NewLogger(config)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slogLogger := bootstrap.NewSlog(logger)
|
||||
notifyChannelRepo := data.NewNotifyChannelRepo(db)
|
||||
notifyUsecase := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
|
||||
taskRunner := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
|
||||
taskRepo := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
|
||||
postgresqlApp := postgresql.NewApp(locale, config, databaseServerRepo, settingRepo, taskRepo)
|
||||
prometheusApp := prometheus.NewApp(config, locale, taskRepo)
|
||||
pureftpdApp := pureftpd.NewApp(locale)
|
||||
|
||||
+531
-33
@@ -1,9 +1,13 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
@@ -25,13 +29,15 @@ type App struct {
|
||||
t *gotext.Locale
|
||||
settingRepo biz.SettingRepo
|
||||
databaseServerRepo biz.DatabaseServerRepo
|
||||
taskRepo biz.TaskRepo
|
||||
}
|
||||
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
|
||||
return &App{
|
||||
t: t,
|
||||
settingRepo: settingRepo,
|
||||
databaseServerRepo: databaseServerRepo,
|
||||
taskRepo: taskRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +50,19 @@ func (s *App) Route(r chi.Router) {
|
||||
r.Post("/root_password", s.SetRootPassword)
|
||||
r.Get("/config_tune", s.GetConfigTune)
|
||||
r.Post("/config_tune", s.UpdateConfigTune)
|
||||
// 性能
|
||||
r.Get("/processes", s.ProcessList)
|
||||
r.Post("/processes/{id}/kill", s.KillProcess)
|
||||
r.Get("/transactions", s.TransactionList)
|
||||
r.Get("/top_sql", s.TopSQL)
|
||||
r.Post("/top_sql/enable", s.EnableTopSQL)
|
||||
r.Post("/top_sql/reset", s.ResetTopSQL)
|
||||
// 维护
|
||||
r.Get("/tables", s.TableList)
|
||||
r.Post("/maintenance", s.RunMaintenance)
|
||||
r.Get("/binlogs", s.BinlogList)
|
||||
r.Post("/binlogs/purge", s.PurgeBinlog)
|
||||
r.Get("/replication", s.ReplicationStatus)
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
@@ -51,38 +70,6 @@ func (s *App) Status() string {
|
||||
return types.AggregateAppStatus(ok)
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, config)
|
||||
}
|
||||
|
||||
// UpdateConfig 保存配置
|
||||
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[UpdateConfig](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = systemctl.Restart("mysqld"); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// Load 获取负载
|
||||
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
|
||||
status, _ := systemctl.Status("mysqld")
|
||||
@@ -188,6 +175,38 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
|
||||
service.Success(w, load)
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, config)
|
||||
}
|
||||
|
||||
// UpdateConfig 保存配置
|
||||
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[UpdateConfig](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = systemctl.Restart("mysqld"); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// SlowLog 获取慢查询日志
|
||||
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
|
||||
service.Success(w, app.Root+"/server/mysql/mysql-slow.log")
|
||||
@@ -335,3 +354,482 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// ProcessList 获取进程列表
|
||||
func (s *App) ProcessList(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
rows, err := mysql.Query(`
|
||||
SELECT ID, coalesce(USER,''), coalesce(HOST,''), coalesce(DB,''), coalesce(COMMAND,''),
|
||||
TIME, coalesce(STATE,''), coalesce(INFO,'')
|
||||
FROM information_schema.PROCESSLIST WHERE ID != CONNECTION_ID() ORDER BY TIME DESC`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
processes := make([]Process, 0)
|
||||
for rows.Next() {
|
||||
var item Process
|
||||
if err = rows.Scan(&item.ID, &item.User, &item.Host, &item.DB, &item.Command, &item.Time, &item.State, &item.Info); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
processes = append(processes, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, processes)
|
||||
}
|
||||
|
||||
// KillProcess 终止进程
|
||||
func (s *App) KillProcess(w http.ResponseWriter, r *http.Request) {
|
||||
id := cast.ToInt64(chi.URLParam(r, "id"))
|
||||
if id <= 0 {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid process id"))
|
||||
return
|
||||
}
|
||||
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
// KILL 不支持预编译参数,id 已校验为正整数
|
||||
if _, err = mysql.Exec(fmt.Sprintf(`KILL %d`, id)); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// TransactionList 获取事务及锁等待列表
|
||||
func (s *App) TransactionList(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
rows, err := mysql.Query(`
|
||||
SELECT trx_id, trx_mysql_thread_id, coalesce(trx_state,''), coalesce(trx_query,''),
|
||||
timestampdiff(SECOND, trx_started, now()), trx_rows_locked, trx_rows_modified
|
||||
FROM information_schema.INNODB_TRX ORDER BY trx_started`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
result := Transactions{Transactions: make([]Transaction, 0), LockWaits: make([]LockWait, 0)}
|
||||
for rows.Next() {
|
||||
var item Transaction
|
||||
if err = rows.Scan(&item.ID, &item.ThreadID, &item.State, &item.Query, &item.Seconds, &item.RowsLocked, &item.RowsModified); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
result.Transactions = append(result.Transactions, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 锁等待对,MariaDB 与 MySQL 8 的表不同,查询失败时忽略
|
||||
lockSQL := `
|
||||
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
|
||||
FROM performance_schema.data_lock_waits w
|
||||
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
|
||||
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID`
|
||||
if s.isMariaDB(mysql) {
|
||||
lockSQL = `
|
||||
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
|
||||
FROM information_schema.INNODB_LOCK_WAITS w
|
||||
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id
|
||||
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id`
|
||||
}
|
||||
if lockRows, lockErr := mysql.Query(lockSQL); lockErr == nil {
|
||||
defer func() { _ = lockRows.Close() }()
|
||||
for lockRows.Next() {
|
||||
var item LockWait
|
||||
if err = lockRows.Scan(&item.WaitingThreadID, &item.WaitingQuery, &item.BlockingThreadID, &item.BlockingQuery); err != nil {
|
||||
break
|
||||
}
|
||||
result.LockWaits = append(result.LockWaits, item)
|
||||
}
|
||||
}
|
||||
|
||||
service.Success(w, result)
|
||||
}
|
||||
|
||||
// TopSQL 获取 SQL 性能统计
|
||||
func (s *App) TopSQL(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
var enabled int
|
||||
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
|
||||
if strings.Contains(err.Error(), "Unknown system variable") {
|
||||
service.Success(w, TopSQL{Supported: false, Items: []TopSQLItem{}})
|
||||
return
|
||||
}
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if enabled == 0 {
|
||||
// 检查是否已配置等待重启
|
||||
config, _ := io.Read(app.Root + "/server/mysql/conf/my.cnf")
|
||||
pending := strings.EqualFold(confval.SectionINI.GetIn(config, "mysqld", "performance_schema"), "on")
|
||||
service.Success(w, TopSQL{Supported: true, Enabled: false, PendingRestart: pending, Items: []TopSQLItem{}})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := mysql.Query(`
|
||||
SELECT coalesce(SCHEMA_NAME,''), COUNT_STAR, round(SUM_TIMER_WAIT/1e9),
|
||||
round(AVG_TIMER_WAIT/1e9,2), SUM_ROWS_SENT, SUM_ROWS_EXAMINED, coalesce(DIGEST_TEXT,'')
|
||||
FROM performance_schema.events_statements_summary_by_digest
|
||||
WHERE DIGEST_TEXT IS NOT NULL ORDER BY SUM_TIMER_WAIT DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]TopSQLItem, 0)
|
||||
for rows.Next() {
|
||||
var item TopSQLItem
|
||||
if err = rows.Scan(&item.Database, &item.Calls, &item.TotalMs, &item.MeanMs, &item.RowsSent, &item.RowsExamined, &item.Query); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, TopSQL{Supported: true, Enabled: true, Items: items})
|
||||
}
|
||||
|
||||
// EnableTopSQL 启用 performance_schema
|
||||
func (s *App) EnableTopSQL(w http.ResponseWriter, r *http.Request) {
|
||||
// 实例不支持时禁止写入配置
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
var enabled int
|
||||
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("performance_schema is not supported by this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
confPath := app.Root + "/server/mysql/conf/my.cnf"
|
||||
config, err := io.Read(confPath)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// my.cnf 分段,必须用 SectionINI 保证写入 [mysqld] 段内,重启后生效
|
||||
config = confval.SectionINI.SetIn(config, "mysqld", "performance_schema", "on")
|
||||
if err = io.Write(confPath, config, 0644); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// ResetTopSQL 重置 SQL 性能统计
|
||||
func (s *App) ResetTopSQL(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
if _, err = mysql.Exec(`TRUNCATE TABLE performance_schema.events_statements_summary_by_digest`); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// TableList 获取表维护信息
|
||||
func (s *App) TableList(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
rows, err := mysql.Query(`
|
||||
SELECT table_schema, table_name, coalesce(engine,''), coalesce(table_rows,0),
|
||||
coalesce(data_length + index_length,0), coalesce(data_free,0)
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY data_length + index_length DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]TableInfo, 0)
|
||||
for rows.Next() {
|
||||
var item TableInfo
|
||||
var size, free int64
|
||||
if err = rows.Scan(&item.Database, &item.Table, &item.Engine, &item.Rows, &size, &free); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
item.Size = tools.FormatBytes(float64(size))
|
||||
if size+free > 0 {
|
||||
item.FragmentRate = float64(free) * 100 / float64(size+free)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, items)
|
||||
}
|
||||
|
||||
// RunMaintenance 对表执行维护操作(异步任务)
|
||||
func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[MaintenanceRun](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !slices.Contains([]string{"optimize", "analyze"}, req.Operation) {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
|
||||
return
|
||||
}
|
||||
|
||||
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
escaped := strings.ReplaceAll(rootPassword, `'`, `'\''`)
|
||||
cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE `%s`.`%s`'", escaped, strings.ToUpper(req.Operation), req.Database, req.Table)
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = fmt.Sprintf("mysql:maintenance:%s.%s", req.Database, req.Table)
|
||||
task.Name = s.t.Get("Run %s on table %s.%s", req.Operation, req.Database, req.Table)
|
||||
task.Status = biz.TaskStatusWaiting
|
||||
task.Shell = cmd
|
||||
if err = s.taskRepo.Push(task); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// BinlogList 获取 binlog 状态
|
||||
func (s *App) BinlogList(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
var enabled int
|
||||
if err = mysql.QueryRow(`SELECT @@log_bin`).Scan(&enabled); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if enabled == 0 {
|
||||
service.Success(w, Binlog{Enabled: false, Items: []BinlogFile{}})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := mysql.Query(`SHOW BINARY LOGS`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
// SHOW BINARY LOGS 列数两派不同(MySQL 8 多 Encrypted 列),动态取列
|
||||
maps, err := s.rowsToMaps(rows)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
binlog := Binlog{Enabled: true, Items: make([]BinlogFile, 0, len(maps))}
|
||||
var total int64
|
||||
for _, m := range maps {
|
||||
size := cast.ToInt64(m["File_size"])
|
||||
total += size
|
||||
binlog.Items = append(binlog.Items, BinlogFile{Name: m["Log_name"], Size: tools.FormatBytes(float64(size))})
|
||||
}
|
||||
binlog.TotalSize = tools.FormatBytes(float64(total))
|
||||
|
||||
service.Success(w, binlog)
|
||||
}
|
||||
|
||||
// PurgeBinlog 清理指定文件之前的 binlog
|
||||
func (s *App) PurgeBinlog(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[BinlogPurge](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
// 校验文件名存在于 binlog 列表,PURGE 不支持预编译参数
|
||||
rows, err := mysql.Query(`SHOW BINARY LOGS`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
maps, err := s.rowsToMaps(rows)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if !slices.ContainsFunc(maps, func(m map[string]string) bool { return m["Log_name"] == req.File }) {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("binlog file %s does not exist", req.File))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = mysql.Exec(fmt.Sprintf(`PURGE BINARY LOGS TO '%s'`, req.File)); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// ReplicationStatus 获取复制状态
|
||||
func (s *App) ReplicationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
mysql, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer mysql.Close()
|
||||
|
||||
rows, err := mysql.Query(`SHOW REPLICA STATUS`)
|
||||
if err != nil {
|
||||
// 老版本 fallback
|
||||
if rows, err = mysql.Query(`SHOW SLAVE STATUS`); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
maps, err := s.rowsToMaps(rows)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if len(maps) == 0 {
|
||||
service.Success(w, Replication{Enabled: false})
|
||||
return
|
||||
}
|
||||
|
||||
// MySQL 8 与 MariaDB 的列名两派不同,按候选名取值
|
||||
pick := func(m map[string]string, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v, ok := m[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
m := maps[0]
|
||||
service.Success(w, Replication{
|
||||
Enabled: true,
|
||||
IORunning: pick(m, "Replica_IO_Running", "Slave_IO_Running"),
|
||||
SQLRunning: pick(m, "Replica_SQL_Running", "Slave_SQL_Running"),
|
||||
SecondsBehind: pick(m, "Seconds_Behind_Source", "Seconds_Behind_Master"),
|
||||
SourceHost: pick(m, "Source_Host", "Master_Host"),
|
||||
LastError: pick(m, "Last_Error"),
|
||||
})
|
||||
}
|
||||
|
||||
// connect 以 root 用户通过 unix socket 连接
|
||||
func (s *App) connect(ctx context.Context) (db.Operator, error) {
|
||||
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db.NewMySQL(ctx, "root", rootPassword, db.MySQLSocket(app.Root), "unix")
|
||||
}
|
||||
|
||||
// isMariaDB 判断当前实例是否为 MariaDB
|
||||
func (s *App) isMariaDB(op db.Operator) bool {
|
||||
var version string
|
||||
if err := op.QueryRow(`SELECT VERSION()`).Scan(&version); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(version), "mariadb")
|
||||
}
|
||||
|
||||
// rowsToMaps 将查询结果按列名转为 map,用于列名/列数不固定的查询
|
||||
func (s *App) rowsToMaps(rows *sql.Rows) ([]map[string]string, error) {
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]string, 0)
|
||||
for rows.Next() {
|
||||
values := make([]sql.NullString, len(columns))
|
||||
scans := make([]any, len(columns))
|
||||
for i := range values {
|
||||
scans[i] = &values[i]
|
||||
}
|
||||
if err = rows.Scan(scans...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
m[column] = values[i].String
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -41,3 +41,104 @@ type ConfigTune struct {
|
||||
SlowQueryLog string `form:"slow_query_log" json:"slow_query_log"`
|
||||
LongQueryTime string `form:"long_query_time" json:"long_query_time"`
|
||||
}
|
||||
|
||||
// MaintenanceRun 表维护操作请求
|
||||
type MaintenanceRun struct {
|
||||
Database string `form:"database" json:"database" validate:"required"`
|
||||
Table string `form:"table" json:"table" validate:"required"`
|
||||
Operation string `form:"operation" json:"operation" validate:"required"`
|
||||
}
|
||||
|
||||
// BinlogPurge binlog 清理请求
|
||||
type BinlogPurge struct {
|
||||
File string `form:"file" json:"file" validate:"required"`
|
||||
}
|
||||
|
||||
// Process 数据库进程信息
|
||||
type Process struct {
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Host string `json:"host"`
|
||||
DB string `json:"db"`
|
||||
Command string `json:"command"`
|
||||
Time int64 `json:"time"`
|
||||
State string `json:"state"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
// Transaction InnoDB 事务信息
|
||||
type Transaction struct {
|
||||
ID string `json:"id"`
|
||||
ThreadID int64 `json:"thread_id"`
|
||||
State string `json:"state"`
|
||||
Query string `json:"query"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
RowsLocked int64 `json:"rows_locked"`
|
||||
RowsModified int64 `json:"rows_modified"`
|
||||
}
|
||||
|
||||
// LockWait 锁等待对
|
||||
type LockWait struct {
|
||||
WaitingThreadID int64 `json:"waiting_thread_id"`
|
||||
WaitingQuery string `json:"waiting_query"`
|
||||
BlockingThreadID int64 `json:"blocking_thread_id"`
|
||||
BlockingQuery string `json:"blocking_query"`
|
||||
}
|
||||
|
||||
// Transactions 事务与锁等待响应
|
||||
type Transactions struct {
|
||||
Transactions []Transaction `json:"transactions"`
|
||||
LockWaits []LockWait `json:"lock_waits"`
|
||||
}
|
||||
|
||||
// TopSQLItem Top SQL 统计项
|
||||
type TopSQLItem struct {
|
||||
Database string `json:"database"`
|
||||
Calls int64 `json:"calls"`
|
||||
TotalMs int64 `json:"total_ms"`
|
||||
MeanMs float64 `json:"mean_ms"`
|
||||
RowsSent int64 `json:"rows_sent"`
|
||||
RowsExamined int64 `json:"rows_examined"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// TopSQL Top SQL 响应
|
||||
type TopSQL struct {
|
||||
Supported bool `json:"supported"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PendingRestart bool `json:"pending_restart"`
|
||||
Items []TopSQLItem `json:"items"`
|
||||
}
|
||||
|
||||
// TableInfo 表维护信息
|
||||
type TableInfo struct {
|
||||
Database string `json:"database"`
|
||||
Table string `json:"table"`
|
||||
Engine string `json:"engine"`
|
||||
Rows int64 `json:"rows"`
|
||||
Size string `json:"size"`
|
||||
FragmentRate float64 `json:"fragment_rate"`
|
||||
}
|
||||
|
||||
// BinlogFile binlog 文件信息
|
||||
type BinlogFile struct {
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
// Binlog binlog 状态响应
|
||||
type Binlog struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TotalSize string `json:"total_size"`
|
||||
Items []BinlogFile `json:"items"`
|
||||
}
|
||||
|
||||
// Replication 复制状态响应
|
||||
type Replication struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IORunning string `json:"io_running"`
|
||||
SQLRunning string `json:"sql_running"`
|
||||
SecondsBehind string `json:"seconds_behind"`
|
||||
SourceHost string `json:"source_host"`
|
||||
LastError string `json:"last_error"`
|
||||
}
|
||||
|
||||
@@ -18,4 +18,17 @@ export default {
|
||||
configTune: (): any => http.Get('/apps/mariadb/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/mariadb/config_tune', data),
|
||||
// 性能
|
||||
processes: (): any => http.Get('/apps/mariadb/processes'),
|
||||
killProcess: (id: number): any => http.Post(`/apps/mariadb/processes/${id}/kill`),
|
||||
transactions: (): any => http.Get('/apps/mariadb/transactions'),
|
||||
topSQL: (): any => http.Get('/apps/mariadb/top_sql'),
|
||||
enableTopSQL: (): any => http.Post('/apps/mariadb/top_sql/enable'),
|
||||
resetTopSQL: (): any => http.Post('/apps/mariadb/top_sql/reset'),
|
||||
// 维护
|
||||
tables: (): any => http.Get('/apps/mariadb/tables'),
|
||||
runMaintenance: (data: any): any => http.Post('/apps/mariadb/maintenance', data),
|
||||
binlogs: (): any => http.Get('/apps/mariadb/binlogs'),
|
||||
purgeBinlog: (file: string): any => http.Post('/apps/mariadb/binlogs/purge', { file }),
|
||||
replication: (): any => http.Get('/apps/mariadb/replication'),
|
||||
}
|
||||
|
||||
@@ -17,4 +17,17 @@ export default {
|
||||
configTune: (): any => http.Get('/apps/mysql/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/mysql/config_tune', data),
|
||||
// 性能
|
||||
processes: (): any => http.Get('/apps/mysql/processes'),
|
||||
killProcess: (id: number): any => http.Post(`/apps/mysql/processes/${id}/kill`),
|
||||
transactions: (): any => http.Get('/apps/mysql/transactions'),
|
||||
topSQL: (): any => http.Get('/apps/mysql/top_sql'),
|
||||
enableTopSQL: (): any => http.Post('/apps/mysql/top_sql/enable'),
|
||||
resetTopSQL: (): any => http.Post('/apps/mysql/top_sql/reset'),
|
||||
// 维护
|
||||
tables: (): any => http.Get('/apps/mysql/tables'),
|
||||
runMaintenance: (data: any): any => http.Post('/apps/mysql/maintenance', data),
|
||||
binlogs: (): any => http.Get('/apps/mysql/binlogs'),
|
||||
purgeBinlog: (file: string): any => http.Post('/apps/mysql/binlogs/purge', { file }),
|
||||
replication: (): any => http.Get('/apps/mysql/replication'),
|
||||
}
|
||||
|
||||
@@ -18,4 +18,17 @@ export default {
|
||||
configTune: (): any => http.Get('/apps/percona/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/percona/config_tune', data),
|
||||
// 性能
|
||||
processes: (): any => http.Get('/apps/percona/processes'),
|
||||
killProcess: (id: number): any => http.Post(`/apps/percona/processes/${id}/kill`),
|
||||
transactions: (): any => http.Get('/apps/percona/transactions'),
|
||||
topSQL: (): any => http.Get('/apps/percona/top_sql'),
|
||||
enableTopSQL: (): any => http.Post('/apps/percona/top_sql/enable'),
|
||||
resetTopSQL: (): any => http.Post('/apps/percona/top_sql/reset'),
|
||||
// 维护
|
||||
tables: (): any => http.Get('/apps/percona/tables'),
|
||||
runMaintenance: (data: any): any => http.Post('/apps/percona/maintenance', data),
|
||||
binlogs: (): any => http.Get('/apps/percona/binlogs'),
|
||||
purgeBinlog: (file: string): any => http.Post('/apps/percona/binlogs/purge', { file }),
|
||||
replication: (): any => http.Get('/apps/percona/replication'),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import systemctl from '@/api/panel/systemctl'
|
||||
import ServiceStatus from '@/components/common/ServiceStatus.vue'
|
||||
|
||||
import MysqlConfigTuneView from './MysqlConfigTuneView.vue'
|
||||
import MysqlMaintenanceView from './MysqlMaintenanceView.vue'
|
||||
import MysqlPerformanceView from './MysqlPerformanceView.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
api: typeof mysql
|
||||
@@ -173,6 +175,12 @@ const handleCopyRootPassword = () => {
|
||||
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
|
||||
<mysql-config-tune-view :api="props.api" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="performance" :tab="$gettext('Performance')">
|
||||
<mysql-performance-view :api="props.api" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="$gettext('Maintenance')">
|
||||
<mysql-maintenance-view :api="props.api" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import type mysql from '@/api/apps/mysql'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
api: typeof mysql
|
||||
}>()
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const { data: tables, send: refreshTables } = useRequest(props.api.tables, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: binlog, send: refreshBinlogs } = useRequest(props.api.binlogs, {
|
||||
initialData: { enabled: true, total_size: '-', items: [] },
|
||||
})
|
||||
const { data: replication, send: refreshReplication } = useRequest(props.api.replication, {
|
||||
initialData: { enabled: false },
|
||||
})
|
||||
|
||||
const handleMaintenance = async (row: any, operation: string) => {
|
||||
const content =
|
||||
operation === 'optimize'
|
||||
? $gettext(
|
||||
'OPTIMIZE will rebuild the InnoDB table, which may take a long time for large tables. Are you sure you want to run it on %{ table }?',
|
||||
{ table: `${row.database}.${row.table}` },
|
||||
)
|
||||
: $gettext('Are you sure you want to run %{ op } on %{ table }?', {
|
||||
op: operation.toUpperCase(),
|
||||
table: `${row.database}.${row.table}`,
|
||||
})
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Operation'),
|
||||
content,
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(
|
||||
props.api.runMaintenance({
|
||||
database: row.database,
|
||||
table: row.table,
|
||||
operation,
|
||||
}),
|
||||
).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
})
|
||||
}
|
||||
|
||||
const tableColumns: any = [
|
||||
{ title: $gettext('Database'), key: 'database', width: 130, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Table'), key: 'table', minWidth: 150, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Engine'), key: 'engine', width: 100 },
|
||||
{ title: $gettext('Rows'), key: 'rows', width: 110 },
|
||||
{ title: $gettext('Size'), key: 'size', width: 100 },
|
||||
{
|
||||
title: $gettext('Fragment Rate'),
|
||||
key: 'fragment_rate',
|
||||
width: 130,
|
||||
render(row: any) {
|
||||
const rate = Math.round(row.fragment_rate * 10) / 10
|
||||
const type = rate >= 30 ? 'error' : rate >= 10 ? 'warning' : 'default'
|
||||
return h(NTag, { type, size: 'small' }, { default: () => `${rate}%` })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 230,
|
||||
render(row: any) {
|
||||
return h(NSpace, { size: 'small', wrap: false }, {
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', type: 'warning', onClick: () => handleMaintenance(row, 'optimize') },
|
||||
{ default: () => 'OPTIMIZE' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', onClick: () => handleMaintenance(row, 'analyze') },
|
||||
{ default: () => 'ANALYZE' },
|
||||
),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const binlogColumns: any = [
|
||||
{ title: $gettext('File'), key: 'name', minWidth: 200 },
|
||||
{ title: $gettext('Size'), key: 'size', width: 120 },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Purge'),
|
||||
content: $gettext(
|
||||
'This will delete all binlogs before %{ file }. If a replica has not applied them yet, replication will break. Are you sure?',
|
||||
{ file: row.name },
|
||||
),
|
||||
positiveText: $gettext('Purge'),
|
||||
countdown: 5,
|
||||
})
|
||||
if (ok) handlePurge(row.name)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Purge to Here') },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const handlePurge = (file: string) => {
|
||||
useRequest(props.api.purgeBinlog(file)).onSuccess(() => {
|
||||
window.$message.success($gettext('Purged successfully'))
|
||||
refreshBinlogs()
|
||||
})
|
||||
}
|
||||
|
||||
const replicationRunning = (value: string) => {
|
||||
return value === 'Yes'
|
||||
? h(NTag, { type: 'success', size: 'small' }, { default: () => $gettext('Running') })
|
||||
: h(NTag, { type: 'error', size: 'small' }, { default: () => value || $gettext('Stopped') })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="tables" :tab="$gettext('Table Maintenance')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshTables()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="tableColumns"
|
||||
:data="tables"
|
||||
:scroll-x="990"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="binlog" :tab="'Binlog'">
|
||||
<n-flex vertical>
|
||||
<n-alert v-if="!binlog.enabled" type="info">
|
||||
{{ $gettext('Binary log is not enabled.') }}
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshBinlogs()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-card>
|
||||
<n-flex>
|
||||
<n-statistic :label="$gettext('File Count')" :value="binlog.items.length" />
|
||||
<n-statistic class="ml-40" :label="$gettext('Total Size')" :value="binlog.total_size" />
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="binlogColumns"
|
||||
:data="binlog.items"
|
||||
:scroll-x="520"
|
||||
max-height="50vh"
|
||||
/>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="replication" :tab="$gettext('Replication')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshReplication()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-alert v-if="!replication.enabled" type="info">
|
||||
{{ $gettext('This instance is not a replica.') }}
|
||||
</n-alert>
|
||||
<n-card v-else :title="$gettext('Replication Status')">
|
||||
<n-descriptions label-placement="left" :column="2">
|
||||
<n-descriptions-item :label="$gettext('Source Host')">
|
||||
{{ replication.source_host || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :label="$gettext('Delay (s)')">
|
||||
{{ replication.seconds_behind || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :label="$gettext('IO Thread')">
|
||||
<component :is="replicationRunning(replication.io_running)" />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :label="$gettext('SQL Thread')">
|
||||
<component :is="replicationRunning(replication.sql_running)" />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="replication.last_error" :label="$gettext('Last Error')" :span="2">
|
||||
{{ replication.last_error }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import type mysql from '@/api/apps/mysql'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
api: typeof mysql
|
||||
}>()
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const enableTopSQLLoading = ref(false)
|
||||
|
||||
const { data: processes, send: refreshProcesses } = useRequest(props.api.processes, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: transactions, send: refreshTransactions } = useRequest(props.api.transactions, {
|
||||
initialData: { transactions: [], lock_waits: [] },
|
||||
})
|
||||
const { data: topSQL, send: refreshTopSQL } = useRequest(props.api.topSQL, {
|
||||
initialData: { supported: true, enabled: true, pending_restart: false, items: [] },
|
||||
})
|
||||
|
||||
const handleKill = (id: number, refresh: () => void) => {
|
||||
useRequest(props.api.killProcess(id)).onSuccess(() => {
|
||||
window.$message.success($gettext('Terminated successfully'))
|
||||
refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const killButton = (id: number, refresh: () => void) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Terminate'),
|
||||
content: $gettext('Are you sure you want to terminate connection %{ id }?', {
|
||||
id: String(id),
|
||||
}),
|
||||
positiveText: $gettext('Terminate'),
|
||||
})
|
||||
if (ok) handleKill(id, refresh)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Terminate') },
|
||||
)
|
||||
|
||||
const processColumns: any = [
|
||||
{ title: 'ID', key: 'id', width: 90 },
|
||||
{ title: $gettext('User'), key: 'user', width: 110, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Host'), key: 'host', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Database'), key: 'db', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Command'), key: 'command', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Duration (s)'), key: 'time', width: 130 },
|
||||
{ title: $gettext('State'), key: 'state', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: 'SQL', key: 'info', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row: any) => killButton(row.id, refreshProcesses),
|
||||
},
|
||||
]
|
||||
|
||||
const transactionColumns: any = [
|
||||
{ title: $gettext('Transaction ID'), key: 'id', width: 140, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Thread ID'), key: 'thread_id', width: 110 },
|
||||
{
|
||||
title: $gettext('State'),
|
||||
key: 'state',
|
||||
width: 130,
|
||||
render(row: any) {
|
||||
const type = row.state === 'LOCK WAIT' ? 'error' : 'default'
|
||||
return h(NTag, { type, size: 'small' }, { default: () => row.state })
|
||||
},
|
||||
},
|
||||
{ title: $gettext('Duration (s)'), key: 'seconds', width: 130 },
|
||||
{ title: $gettext('Rows Locked'), key: 'rows_locked', width: 130 },
|
||||
{ title: $gettext('Rows Modified'), key: 'rows_modified', width: 140 },
|
||||
{ title: 'SQL', key: 'query', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row: any) => killButton(row.thread_id, refreshTransactions),
|
||||
},
|
||||
]
|
||||
|
||||
const lockWaitColumns: any = [
|
||||
{ title: $gettext('Waiting Thread'), key: 'waiting_thread_id', width: 140 },
|
||||
{ title: $gettext('Waiting SQL'), key: 'waiting_query', minWidth: 200, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Blocking Thread'), key: 'blocking_thread_id', width: 140 },
|
||||
{
|
||||
title: $gettext('Blocking SQL'),
|
||||
key: 'blocking_query',
|
||||
minWidth: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (row: any) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Terminate'),
|
||||
content: $gettext(
|
||||
'Are you sure you want to terminate the blocking connection %{ id }?',
|
||||
{ id: String(row.blocking_thread_id) },
|
||||
),
|
||||
positiveText: $gettext('Terminate'),
|
||||
})
|
||||
if (ok) handleKill(row.blocking_thread_id, refreshTransactions)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Terminate Blocker') },
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const topSQLColumns: any = [
|
||||
{ title: $gettext('Database'), key: 'database', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Calls'), key: 'calls', width: 100 },
|
||||
{ title: $gettext('Total Time (ms)'), key: 'total_ms', width: 150 },
|
||||
{ title: $gettext('Mean Time (ms)'), key: 'mean_ms', width: 150 },
|
||||
{ title: $gettext('Rows Sent'), key: 'rows_sent', width: 120 },
|
||||
{ title: $gettext('Rows Examined'), key: 'rows_examined', width: 150 },
|
||||
{ title: 'SQL', key: 'query', minWidth: 300, ellipsis: { tooltip: true } },
|
||||
]
|
||||
|
||||
const handleEnableTopSQL = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Enable'),
|
||||
content: $gettext(
|
||||
'This will enable performance_schema, which increases memory usage (use with caution on low-memory servers). A restart is required to take effect.',
|
||||
),
|
||||
})
|
||||
if (!ok) return
|
||||
enableTopSQLLoading.value = true
|
||||
useRequest(props.api.enableTopSQL())
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Enabled, please restart the service to take effect'))
|
||||
refreshTopSQL()
|
||||
})
|
||||
.onComplete(() => {
|
||||
enableTopSQLLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetTopSQL = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Reset'),
|
||||
content: $gettext('Are you sure you want to reset all SQL statistics?'),
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(props.api.resetTopSQL()).onSuccess(() => {
|
||||
window.$message.success($gettext('Reset successfully'))
|
||||
refreshTopSQL()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="processes" :tab="$gettext('Processes')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshProcesses()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="processColumns"
|
||||
:data="processes"
|
||||
:scroll-x="1350"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="transactions" :tab="$gettext('Transactions & Locks')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshTransactions()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-alert v-if="transactions.lock_waits.length" type="warning">
|
||||
{{ $gettext('Lock waits detected, check the blocking connections below.') }}
|
||||
</n-alert>
|
||||
<n-data-table
|
||||
v-if="transactions.lock_waits.length"
|
||||
striped
|
||||
:columns="lockWaitColumns"
|
||||
:data="transactions.lock_waits"
|
||||
:scroll-x="1000"
|
||||
/>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="transactionColumns"
|
||||
:data="transactions.transactions"
|
||||
:scroll-x="1300"
|
||||
max-height="50vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="top-sql" :tab="'Top SQL'">
|
||||
<n-flex vertical>
|
||||
<n-alert v-if="!topSQL.supported" type="warning">
|
||||
{{
|
||||
$gettext(
|
||||
'This instance was built without performance_schema support, SQL statistics are not available.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-alert v-else-if="!topSQL.enabled && topSQL.pending_restart" type="warning">
|
||||
{{ $gettext('performance_schema is configured, restart the service to take effect.') }}
|
||||
</n-alert>
|
||||
<n-alert v-else-if="!topSQL.enabled" type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'performance_schema is not enabled. After enabling, SQL performance statistics will be collected, which increases memory usage and requires a restart.',
|
||||
)
|
||||
}}
|
||||
<n-button
|
||||
class="ml-16"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="enableTopSQLLoading"
|
||||
:disabled="enableTopSQLLoading"
|
||||
@click="handleEnableTopSQL"
|
||||
>
|
||||
{{ $gettext('Enable') }}
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<template v-if="topSQL.enabled">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshTopSQL()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
<n-button type="warning" @click="handleResetTopSQL">
|
||||
{{ $gettext('Reset Statistics') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="topSQLColumns"
|
||||
:data="topSQL.items"
|
||||
:scroll-x="1350"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -66,7 +66,7 @@ const columns: any = [
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
width: 300,
|
||||
render(row: any) {
|
||||
if (!row.installed) {
|
||||
return h(
|
||||
@@ -88,7 +88,7 @@ const columns: any = [
|
||||
{ default: () => $gettext('Install') },
|
||||
)
|
||||
}
|
||||
return h(NSpace, { size: 'small' }, {
|
||||
return h(NSpace, { size: 'small', wrap: false }, {
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
@@ -188,7 +188,7 @@ const handleUninstall = (slug: string) => {
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-data-table striped :columns="columns" :data="extensions" :scroll-x="940" />
|
||||
<n-data-table striped :columns="columns" :data="extensions" :scroll-x="1000" />
|
||||
<n-modal
|
||||
v-model:show="showEnableModal"
|
||||
preset="card"
|
||||
|
||||
@@ -74,12 +74,12 @@ const bloatColumns: any = [
|
||||
{ title: $gettext('Schema'), key: 'schema', width: 110, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Table'), key: 'table', minWidth: 150, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Size'), key: 'size', width: 100 },
|
||||
{ title: $gettext('Live Tuples'), key: 'live_tuples', width: 110 },
|
||||
{ title: $gettext('Dead Tuples'), key: 'dead_tuples', width: 110 },
|
||||
{ title: $gettext('Live Tuples'), key: 'live_tuples', width: 120 },
|
||||
{ title: $gettext('Dead Tuples'), key: 'dead_tuples', width: 130 },
|
||||
{
|
||||
title: $gettext('Dead Rate'),
|
||||
key: 'dead_rate',
|
||||
width: 100,
|
||||
width: 120,
|
||||
render(row: any) {
|
||||
const type = row.dead_rate >= 20 ? 'error' : row.dead_rate >= 10 ? 'warning' : 'default'
|
||||
return h(NTag, { type, size: 'small' }, { default: () => `${row.dead_rate}%` })
|
||||
@@ -88,19 +88,19 @@ const bloatColumns: any = [
|
||||
{
|
||||
title: $gettext('Last Vacuum'),
|
||||
key: 'last_vacuum',
|
||||
width: 140,
|
||||
width: 150,
|
||||
render: (row: any) => row.last_vacuum || row.last_autovacuum || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Last Analyze'),
|
||||
key: 'last_analyze',
|
||||
width: 140,
|
||||
width: 150,
|
||||
render: (row: any) => row.last_analyze || row.last_autoanalyze || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
width: 310,
|
||||
render(row: any) {
|
||||
const buttons = [
|
||||
h(
|
||||
@@ -136,7 +136,7 @@ const bloatColumns: any = [
|
||||
),
|
||||
)
|
||||
}
|
||||
return h(NSpace, { size: 'small' }, { default: () => buttons })
|
||||
return h(NSpace, { size: 'small', wrap: false }, { default: () => buttons })
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -154,7 +154,7 @@ const slotColumns: any = [
|
||||
: h(NTag, { type: 'warning', size: 'small' }, { default: () => $gettext('No') })
|
||||
},
|
||||
},
|
||||
{ title: $gettext('Retained WAL'), key: 'retained_wal', width: 130 },
|
||||
{ title: $gettext('Retained WAL'), key: 'retained_wal', width: 150 },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
@@ -219,7 +219,7 @@ const handleDropSlot = (name: string) => {
|
||||
striped
|
||||
:columns="bloatColumns"
|
||||
:data="bloat.items"
|
||||
:scroll-x="1200"
|
||||
:scroll-x="1370"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
@@ -247,7 +247,7 @@ const handleDropSlot = (name: string) => {
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card :title="$gettext('Replication Slots')">
|
||||
<n-data-table striped :columns="slotColumns" :data="wal.slots" :scroll-x="620" />
|
||||
<n-data-table striped :columns="slotColumns" :data="wal.slots" :scroll-x="680" />
|
||||
</n-card>
|
||||
<n-card v-if="wal.replications.length" :title="$gettext('Replication Status')">
|
||||
<n-data-table
|
||||
|
||||
@@ -56,20 +56,20 @@ const sessionColumns: any = [
|
||||
{
|
||||
title: $gettext('Transaction Duration'),
|
||||
key: 'xact_seconds',
|
||||
width: 110,
|
||||
width: 170,
|
||||
render: (row: any) => formatDuration(row.xact_seconds),
|
||||
},
|
||||
{
|
||||
title: $gettext('Query Duration'),
|
||||
key: 'query_seconds',
|
||||
width: 110,
|
||||
width: 150,
|
||||
render: (row: any) => formatDuration(row.query_seconds),
|
||||
},
|
||||
{ title: 'SQL', key: 'query', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
width: 120,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
@@ -96,13 +96,13 @@ const sessionColumns: any = [
|
||||
const topSQLColumns: any = [
|
||||
{ title: $gettext('Database'), key: 'database', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Calls'), key: 'calls', width: 100 },
|
||||
{ title: $gettext('Total Time (ms)'), key: 'total_ms', width: 130 },
|
||||
{ title: $gettext('Mean Time (ms)'), key: 'mean_ms', width: 130 },
|
||||
{ title: $gettext('Total Time (ms)'), key: 'total_ms', width: 150 },
|
||||
{ title: $gettext('Mean Time (ms)'), key: 'mean_ms', width: 150 },
|
||||
{ title: $gettext('Rows'), key: 'rows', width: 100 },
|
||||
{
|
||||
title: $gettext('Cache Hit Rate'),
|
||||
key: 'hit_rate',
|
||||
width: 120,
|
||||
width: 140,
|
||||
render: (row: any) => `${row.hit_rate}%`,
|
||||
},
|
||||
{ title: 'SQL', key: 'query', minWidth: 300, ellipsis: { tooltip: true } },
|
||||
@@ -162,7 +162,7 @@ const handleResetTopSQL = async () => {
|
||||
striped
|
||||
:columns="sessionColumns"
|
||||
:data="sessions"
|
||||
:scroll-x="1500"
|
||||
:scroll-x="1700"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
@@ -204,7 +204,7 @@ const handleResetTopSQL = async () => {
|
||||
striped
|
||||
:columns="topSQLColumns"
|
||||
:data="topSQL.items"
|
||||
:scroll-x="1200"
|
||||
:scroll-x="1300"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user