feat: MySQL 添加进程/事务锁/TopSQL/表维护/binlog/复制运维功能

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-19 21:43:36 +08:00
parent ecd403a334
commit d69caa6aed
12 changed files with 1194 additions and 64 deletions
+10 -10
View File
@@ -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
View File
@@ -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()
}
+101
View File
@@ -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"`
}
+13
View File
@@ -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'),
}
+13
View File
@@ -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'),
}
+13
View File
@@ -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'),
}
+8
View File
@@ -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>