mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 01:12:17 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a364076e73 | |||
| f1a984deb3 | |||
| ad98ebce57 | |||
| 3e453f4510 | |||
| 77aa2a3450 | |||
| 0dfe577fc4 | |||
| af23f49811 | |||
| d69caa6aed | |||
| ecd403a334 | |||
| a729718fd9 | |||
| cab0e0ae2b | |||
| 5c729082a5 | |||
| a7e17c9cbe | |||
| 6a888b14f6 | |||
| 5de88d4846 | |||
| c5a3858601 | |||
| 73bc213a0f | |||
| 2dcd3dcdc6 | |||
| 4e67b31d40 | |||
| c1b1278db4 | |||
| 5391911a97 | |||
| 335fbf697f |
+13
-13
@@ -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,24 +99,15 @@ func initAce() (*app.Ace, func(), error) {
|
||||
pgadminApp := pgadmin.NewApp(config, locale, databaseServerRepo)
|
||||
phpmyadminApp := phpmyadmin.NewApp(config, locale, databaseServerRepo)
|
||||
podmanApp := podman.NewApp()
|
||||
postgresqlApp := postgresql.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)
|
||||
postgresqlApp := postgresql.NewApp(locale, config, databaseServerRepo, settingRepo, taskRepo)
|
||||
prometheusApp := prometheus.NewApp(config, locale, taskRepo)
|
||||
pureftpdApp := pureftpd.NewApp(locale)
|
||||
redisApp := redis.NewApp(locale, databaseServerRepo)
|
||||
redisApp := redis.NewApp(locale, databaseServerRepo, taskRepo)
|
||||
rocketmqApp := rocketmq.NewApp(locale)
|
||||
rsyncApp := rsync.NewApp(locale)
|
||||
s3fsApp := s3fs.NewApp(locale)
|
||||
supervisorApp := supervisor.NewApp(locale)
|
||||
valkeyApp := valkey.NewApp(locale, databaseServerRepo)
|
||||
valkeyApp := valkey.NewApp(locale, databaseServerRepo, taskRepo)
|
||||
loader := bootstrap.NewLoader(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
|
||||
manager, err := bootstrap.NewSession(config, db, slogLogger)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/app"
|
||||
"github.com/acepanel/panel/v3/internal/apps/confval"
|
||||
"github.com/acepanel/panel/v3/internal/service"
|
||||
"github.com/acepanel/panel/v3/pkg/io"
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/acepanel/panel/v3/pkg/types"
|
||||
)
|
||||
|
||||
var mpmEventRegexp = regexp.MustCompile(`(?s)<IfModule mpm_event_module>(.*?)</IfModule>`)
|
||||
|
||||
type App struct {
|
||||
t *gotext.Locale
|
||||
}
|
||||
@@ -34,6 +37,8 @@ func (s *App) Route(r chi.Router) {
|
||||
r.Post("/config", s.SaveConfig)
|
||||
r.Get("/error_log", s.ErrorLog)
|
||||
r.Post("/clear_error_log", s.ClearErrorLog)
|
||||
r.Get("/config_tune", s.GetConfigTune)
|
||||
r.Post("/config_tune", s.UpdateConfigTune)
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
@@ -160,3 +165,83 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
service.Success(w, data)
|
||||
}
|
||||
|
||||
// GetConfigTune 获取 Apache 配置调整参数
|
||||
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
defaultConf, err := io.Read(app.Root + "/server/apache/conf/extra/httpd-default.conf")
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
mpmConf, _ := io.Read(app.Root + "/server/apache/conf/extra/httpd-mpm.conf")
|
||||
eventBlock := ""
|
||||
if m := mpmEventRegexp.FindStringSubmatch(mpmConf); len(m) > 1 {
|
||||
eventBlock = m[1]
|
||||
}
|
||||
|
||||
// 面板统一写入 httpd-default.conf,未写入过的从 httpd-mpm.conf 的 event 块读取默认值
|
||||
get := func(key string) string {
|
||||
if v := confval.Directive.Get(defaultConf, key); v != "" {
|
||||
return v
|
||||
}
|
||||
return confval.Directive.Get(eventBlock, key)
|
||||
}
|
||||
|
||||
tune := ConfigTune{
|
||||
// MPM 事件模型
|
||||
StartServers: get("StartServers"),
|
||||
MinSpareThreads: get("MinSpareThreads"),
|
||||
MaxSpareThreads: get("MaxSpareThreads"),
|
||||
ThreadsPerChild: get("ThreadsPerChild"),
|
||||
MaxRequestWorkers: get("MaxRequestWorkers"),
|
||||
MaxConnectionsPerChild: get("MaxConnectionsPerChild"),
|
||||
// 连接设置
|
||||
Timeout: get("Timeout"),
|
||||
KeepAlive: get("KeepAlive"),
|
||||
MaxKeepAliveRequests: get("MaxKeepAliveRequests"),
|
||||
KeepAliveTimeout: get("KeepAliveTimeout"),
|
||||
}
|
||||
|
||||
service.Success(w, tune)
|
||||
}
|
||||
|
||||
// UpdateConfigTune 更新 Apache 配置调整参数
|
||||
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[ConfigTune](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
confPath := app.Root + "/server/apache/conf/extra/httpd-default.conf"
|
||||
config, err := io.Read(confPath)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// MPM 参数一并写入 httpd-default.conf,其 Include 顺序在 httpd-mpm.conf 之后,顶层定义覆盖块内默认值
|
||||
config = confval.Directive.Set(config, "StartServers", req.StartServers)
|
||||
config = confval.Directive.Set(config, "MinSpareThreads", req.MinSpareThreads)
|
||||
config = confval.Directive.Set(config, "MaxSpareThreads", req.MaxSpareThreads)
|
||||
config = confval.Directive.Set(config, "ThreadsPerChild", req.ThreadsPerChild)
|
||||
config = confval.Directive.Set(config, "MaxRequestWorkers", req.MaxRequestWorkers)
|
||||
config = confval.Directive.Set(config, "MaxConnectionsPerChild", req.MaxConnectionsPerChild)
|
||||
config = confval.Directive.Set(config, "Timeout", req.Timeout)
|
||||
config = confval.Directive.Set(config, "KeepAlive", req.KeepAlive)
|
||||
config = confval.Directive.Set(config, "MaxKeepAliveRequests", req.MaxKeepAliveRequests)
|
||||
config = confval.Directive.Set(config, "KeepAliveTimeout", req.KeepAliveTimeout)
|
||||
|
||||
if err = io.Write(confPath, config, 0644); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = systemctl.Reload("apache"); err != nil {
|
||||
out, _ := shell.Execf("%s/server/apache/bin/apachectl configtest", app.Root)
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload apache: %v %s", err, out))
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
@@ -3,3 +3,19 @@ package apache
|
||||
type UpdateConfig struct {
|
||||
Config string `form:"config" json:"config" validate:"required"`
|
||||
}
|
||||
|
||||
// ConfigTune Apache 配置调整
|
||||
type ConfigTune struct {
|
||||
// MPM 事件模型
|
||||
StartServers string `form:"start_servers" json:"start_servers"`
|
||||
MinSpareThreads string `form:"min_spare_threads" json:"min_spare_threads"`
|
||||
MaxSpareThreads string `form:"max_spare_threads" json:"max_spare_threads"`
|
||||
ThreadsPerChild string `form:"threads_per_child" json:"threads_per_child"`
|
||||
MaxRequestWorkers string `form:"max_request_workers" json:"max_request_workers"`
|
||||
MaxConnectionsPerChild string `form:"max_connections_per_child" json:"max_connections_per_child"`
|
||||
// 连接设置
|
||||
Timeout string `form:"timeout" json:"timeout"`
|
||||
KeepAlive string `form:"keep_alive" json:"keep_alive"`
|
||||
MaxKeepAliveRequests string `form:"max_keep_alive_requests" json:"max_keep_alive_requests"`
|
||||
KeepAliveTimeout string `form:"keep_alive_timeout" json:"keep_alive_timeout"`
|
||||
}
|
||||
|
||||
+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"`
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/app"
|
||||
"github.com/acepanel/panel/v3/internal/apps/confval"
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
"github.com/acepanel/panel/v3/internal/service"
|
||||
"github.com/acepanel/panel/v3/pkg/config"
|
||||
"github.com/acepanel/panel/v3/pkg/db"
|
||||
"github.com/acepanel/panel/v3/pkg/io"
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
@@ -19,20 +27,23 @@ import (
|
||||
"github.com/acepanel/panel/v3/pkg/types"
|
||||
)
|
||||
|
||||
var defaultVersionRegexp = regexp.MustCompile(`default_version\s*=\s*'([^']+)'`)
|
||||
|
||||
type App struct {
|
||||
t *gotext.Locale
|
||||
conf *config.Config
|
||||
settingRepo biz.SettingRepo
|
||||
databaseServerRepo biz.DatabaseServerRepo
|
||||
taskRepo biz.TaskRepo
|
||||
}
|
||||
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
|
||||
|
||||
setting := settingRepo
|
||||
databaseServer := databaseServerRepo
|
||||
func NewApp(t *gotext.Locale, conf *config.Config, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
|
||||
return &App{
|
||||
t: t,
|
||||
settingRepo: setting,
|
||||
databaseServerRepo: databaseServer,
|
||||
conf: conf,
|
||||
settingRepo: settingRepo,
|
||||
databaseServerRepo: databaseServerRepo,
|
||||
taskRepo: taskRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +58,23 @@ func (s *App) Route(r chi.Router) {
|
||||
r.Post("/postgres_password", s.SetPostgresPassword)
|
||||
r.Get("/config_tune", s.GetConfigTune)
|
||||
r.Post("/config_tune", s.UpdateConfigTune)
|
||||
// 扩展管理
|
||||
r.Get("/extensions", s.ExtensionList)
|
||||
r.Post("/extensions", s.InstallExtension)
|
||||
r.Delete("/extensions", s.UninstallExtension)
|
||||
r.Post("/extensions/enable", s.EnableExtension)
|
||||
// 性能
|
||||
r.Get("/sessions", s.SessionList)
|
||||
r.Post("/sessions/{pid}/terminate", s.TerminateSession)
|
||||
r.Get("/top_sql", s.TopSQL)
|
||||
r.Post("/top_sql/enable", s.EnableTopSQL)
|
||||
r.Post("/top_sql/reset", s.ResetTopSQL)
|
||||
// 维护
|
||||
r.Get("/databases", s.DatabaseList)
|
||||
r.Get("/bloat", s.BloatList)
|
||||
r.Post("/maintenance", s.RunMaintenance)
|
||||
r.Get("/wal", s.WalStatus)
|
||||
r.Delete("/replication_slots/{slot}", s.DropReplicationSlot)
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
@@ -329,6 +357,526 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// ExtensionList 获取扩展列表及安装状态
|
||||
func (s *App) ExtensionList(w http.ResponseWriter, r *http.Request) {
|
||||
extensions := s.getExtensions()
|
||||
for i := range extensions {
|
||||
controlPath := fmt.Sprintf("%s/server/postgresql/share/extension/%s.control", app.Root, extensions[i].ExtName)
|
||||
extensions[i].Installed = io.Exists(controlPath)
|
||||
if extensions[i].Installed {
|
||||
control, _ := io.Read(controlPath)
|
||||
if m := defaultVersionRegexp.FindStringSubmatch(control); len(m) > 1 {
|
||||
extensions[i].InstalledVersion = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.Success(w, extensions)
|
||||
}
|
||||
|
||||
// InstallExtension 安装扩展(异步任务)
|
||||
func (s *App) InstallExtension(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[ExtensionSlug](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.checkExtension(req.Slug) {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/postgresql/extensions/%s.sh' | bash -s -- 'install'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = "postgresql:extension:" + req.Slug
|
||||
task.Name = s.t.Get("Install PostgreSQL extension %s", req.Slug)
|
||||
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)
|
||||
}
|
||||
|
||||
// UninstallExtension 卸载扩展(异步任务)
|
||||
func (s *App) UninstallExtension(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[ExtensionSlug](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.checkExtension(req.Slug) {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/postgresql/extensions/%s.sh' | bash -s -- 'uninstall'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = "postgresql:extension:" + req.Slug
|
||||
task.Name = s.t.Get("Uninstall PostgreSQL extension %s", req.Slug)
|
||||
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)
|
||||
}
|
||||
|
||||
// EnableExtension 在指定数据库启用扩展
|
||||
func (s *App) EnableExtension(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[ExtensionEnable](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ext, ok := lo.Find(s.getExtensions(), func(e Extension) bool {
|
||||
return e.Slug == req.Slug
|
||||
})
|
||||
if !ok {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
postgres, err := s.connect(r.Context(), req.Database)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
if _, err = postgres.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext.ExtName)); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to enable extension: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// SessionList 获取会话列表
|
||||
func (s *App) SessionList(w http.ResponseWriter, r *http.Request) {
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
rows, err := postgres.Query(`
|
||||
SELECT a.pid, coalesce(a.datname,''), coalesce(a.usename,''), coalesce(a.client_addr::text,''),
|
||||
coalesce(a.state,''), coalesce(a.wait_event_type,''), coalesce(a.wait_event,''),
|
||||
coalesce(array_to_string(pg_blocking_pids(a.pid),','),''),
|
||||
coalesce(extract(epoch from (now()-a.xact_start))::bigint,0),
|
||||
coalesce(extract(epoch from (now()-a.query_start))::bigint,0),
|
||||
coalesce(a.query,'')
|
||||
FROM pg_stat_activity a
|
||||
WHERE a.pid != pg_backend_pid() AND a.backend_type = 'client backend'
|
||||
ORDER BY a.backend_start`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
sessions := make([]Session, 0)
|
||||
for rows.Next() {
|
||||
var item Session
|
||||
if err = rows.Scan(&item.PID, &item.Database, &item.User, &item.ClientAddr, &item.State,
|
||||
&item.WaitEventType, &item.WaitEvent, &item.BlockedBy, &item.XactSeconds, &item.QuerySeconds, &item.Query); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
sessions = append(sessions, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, sessions)
|
||||
}
|
||||
|
||||
// TerminateSession 终止会话
|
||||
func (s *App) TerminateSession(w http.ResponseWriter, r *http.Request) {
|
||||
pid := cast.ToInt64(chi.URLParam(r, "pid"))
|
||||
if pid <= 0 {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid pid"))
|
||||
return
|
||||
}
|
||||
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
if _, err = postgres.Exec(`SELECT pg_terminate_backend($1)`, pid); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// TopSQL 获取 SQL 性能统计
|
||||
func (s *App) TopSQL(w http.ResponseWriter, r *http.Request) {
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
rows, err := postgres.Query(`
|
||||
SELECT coalesce(d.datname,''), s.calls, round(s.total_exec_time)::bigint,
|
||||
round(s.mean_exec_time::numeric,2)::float8, s.rows,
|
||||
coalesce(round(100.0*s.shared_blks_hit/nullif(s.shared_blks_hit+s.shared_blks_read,0),1),0)::float8,
|
||||
s.query
|
||||
FROM pg_stat_statements s LEFT JOIN pg_database d ON d.oid = s.dbid
|
||||
ORDER BY s.total_exec_time DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
// pg_stat_statements 未启用时返回状态而非报错
|
||||
if strings.Contains(err.Error(), "shared_preload_libraries") {
|
||||
service.Success(w, TopSQL{Enabled: false, PendingRestart: true, Items: []TopSQLItem{}})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
service.Success(w, TopSQL{Enabled: false, Items: []TopSQLItem{}})
|
||||
return
|
||||
}
|
||||
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.Rows, &item.HitRate, &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{Enabled: true, Items: items})
|
||||
}
|
||||
|
||||
// EnableTopSQL 启用 pg_stat_statements
|
||||
func (s *App) EnableTopSQL(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := io.Read(s.configPath())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 将 pg_stat_statements 加入 shared_preload_libraries,重启后生效
|
||||
var libs []string
|
||||
for lib := range strings.SplitSeq(confval.Postgres.Get(config, "shared_preload_libraries"), ",") {
|
||||
if lib = strings.TrimSpace(lib); lib != "" {
|
||||
libs = append(libs, lib)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(libs, "pg_stat_statements") {
|
||||
libs = append(libs, "pg_stat_statements")
|
||||
config = confval.Postgres.Set(config, "shared_preload_libraries", strings.Join(libs, ","))
|
||||
if err = io.Write(s.configPath(), config, 0644); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
if _, err = postgres.Exec(`CREATE EXTENSION IF NOT EXISTS pg_stat_statements`); 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) {
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
if _, err = postgres.Exec(`SELECT pg_stat_statements_reset()`); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// DatabaseList 获取可连接的数据库列表
|
||||
func (s *App) DatabaseList(w http.ResponseWriter, r *http.Request) {
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
rows, err := postgres.Query(`SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
databases := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err = rows.Scan(&name); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
databases = append(databases, name)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, databases)
|
||||
}
|
||||
|
||||
// BloatList 获取指定数据库的表膨胀情况
|
||||
func (s *App) BloatList(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[BloatQuery](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
postgres, err := s.connect(r.Context(), req.Database)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
rows, err := postgres.Query(`
|
||||
SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(relid)),
|
||||
n_live_tup, n_dead_tup,
|
||||
coalesce(round(100.0*n_dead_tup/nullif(n_live_tup+n_dead_tup,0),1),0)::float8,
|
||||
coalesce(to_char(last_vacuum,'YYYY-MM-DD HH24:MI'),''), coalesce(to_char(last_autovacuum,'YYYY-MM-DD HH24:MI'),''),
|
||||
coalesce(to_char(last_analyze,'YYYY-MM-DD HH24:MI'),''), coalesce(to_char(last_autoanalyze,'YYYY-MM-DD HH24:MI'),'')
|
||||
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]BloatItem, 0)
|
||||
for rows.Next() {
|
||||
var item BloatItem
|
||||
if err = rows.Scan(&item.Schema, &item.Table, &item.Size, &item.LiveTuples, &item.DeadTuples,
|
||||
&item.DeadRate, &item.LastVacuum, &item.LastAutovacuum, &item.LastAnalyze, &item.LastAutoanalyz); 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, Bloat{
|
||||
RepackInstalled: io.Exists(app.Root + "/server/postgresql/share/extension/pg_repack.control"),
|
||||
Items: 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{"vacuum", "analyze", "repack"}, req.Operation) {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
|
||||
return
|
||||
}
|
||||
|
||||
var cmd string
|
||||
switch req.Operation {
|
||||
case "vacuum":
|
||||
cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "VACUUM \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table)
|
||||
case "analyze":
|
||||
cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "ANALYZE \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table)
|
||||
case "repack":
|
||||
if !io.Exists(app.Root + "/server/postgresql/share/extension/pg_repack.control") {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("pg_repack is not installed, please install it in the extensions tab first"))
|
||||
return
|
||||
}
|
||||
cmd = fmt.Sprintf(`su - postgres -c 'pg_repack -d "%s" -t "%s.%s"'`, req.Database, req.Schema, req.Table)
|
||||
}
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = fmt.Sprintf("postgresql:maintenance:%s:%s.%s", req.Database, req.Schema, req.Table)
|
||||
task.Name = s.t.Get("Run %s on table %s.%s of database %s", req.Operation, req.Schema, req.Table, req.Database)
|
||||
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)
|
||||
}
|
||||
|
||||
// WalStatus 获取 WAL 及复制状态
|
||||
func (s *App) WalStatus(w http.ResponseWriter, r *http.Request) {
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
var wal Wal
|
||||
if err = postgres.QueryRow(`SELECT pg_size_pretty(coalesce(sum(size),0)) FROM pg_ls_waldir()`).Scan(&wal.WalSize); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if err = postgres.QueryRow(`SELECT archived_count, failed_count, coalesce(last_archived_wal,''), coalesce(last_failed_wal,'') FROM pg_stat_archiver`).Scan(
|
||||
&wal.Archiver.ArchivedCount, &wal.Archiver.FailedCount, &wal.Archiver.LastArchivedWal, &wal.Archiver.LastFailedWal); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
slotRows, err := postgres.Query(`SELECT slot_name, slot_type, active, coalesce(pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)),'') FROM pg_replication_slots`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = slotRows.Close() }()
|
||||
wal.Slots = make([]ReplicationSlot, 0)
|
||||
for slotRows.Next() {
|
||||
var slot ReplicationSlot
|
||||
if err = slotRows.Scan(&slot.Name, &slot.Type, &slot.Active, &slot.RetainedWal); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
wal.Slots = append(wal.Slots, slot)
|
||||
}
|
||||
if err = slotRows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
replRows, err := postgres.Query(`SELECT coalesce(client_addr::text,''), coalesce(state,''), coalesce(sync_state,''), coalesce(pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)),'') FROM pg_stat_replication`)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = replRows.Close() }()
|
||||
wal.Replications = make([]Replication, 0)
|
||||
for replRows.Next() {
|
||||
var repl Replication
|
||||
if err = replRows.Scan(&repl.ClientAddr, &repl.State, &repl.SyncState, &repl.Lag); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
wal.Replications = append(wal.Replications, repl)
|
||||
}
|
||||
if err = replRows.Err(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, wal)
|
||||
}
|
||||
|
||||
// DropReplicationSlot 删除复制槽
|
||||
func (s *App) DropReplicationSlot(w http.ResponseWriter, r *http.Request) {
|
||||
slot := chi.URLParam(r, "slot")
|
||||
if slot == "" {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid slot name"))
|
||||
return
|
||||
}
|
||||
|
||||
postgres, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer postgres.Close()
|
||||
|
||||
if _, err = postgres.Exec(`SELECT pg_drop_replication_slot($1)`, slot); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// getExtensions 返回所有扩展定义
|
||||
func (s *App) getExtensions() []Extension {
|
||||
return []Extension{
|
||||
{Name: "pgvector", Slug: "pgvector", ExtName: "vector", Description: s.t.Get("Vector similarity search")},
|
||||
{Name: "PostGIS", Slug: "postgis", ExtName: "postgis", Description: s.t.Get("Spatial and geographic objects support")},
|
||||
{Name: "TimescaleDB", Slug: "timescaledb", ExtName: "timescaledb", Description: s.t.Get("Time-series database engine, requires restarting PostgreSQL after installation")},
|
||||
{Name: "zhparser", Slug: "zhparser", ExtName: "zhparser", Description: s.t.Get("Chinese full-text search parser based on SCWS")},
|
||||
{Name: "pg_repack", Slug: "pg_repack", ExtName: "pg_repack", Description: s.t.Get("Reorganize tables online to remove bloat")},
|
||||
{Name: "pg_cron", Slug: "pg_cron", ExtName: "pg_cron", Description: s.t.Get("Run periodic jobs inside the database, requires restarting PostgreSQL after installation")},
|
||||
{Name: "pg_partman", Slug: "pg_partman", ExtName: "pg_partman", Description: s.t.Get("Automated partition management")},
|
||||
{Name: "pgaudit", Slug: "pgaudit", ExtName: "pgaudit", Description: s.t.Get("Session and object audit logging, requires restarting PostgreSQL after installation")},
|
||||
{Name: "pg_hint_plan", Slug: "pg_hint_plan", ExtName: "pg_hint_plan", Description: s.t.Get("Control execution plans with hints in SQL comments, requires restarting PostgreSQL after installation")},
|
||||
{Name: "pg_stat_monitor", Slug: "pg_stat_monitor", ExtName: "pg_stat_monitor", Description: s.t.Get("Advanced query performance monitoring, requires restarting PostgreSQL after installation")},
|
||||
{Name: "pg_ivm", Slug: "pg_ivm", ExtName: "pg_ivm", Description: s.t.Get("Incremental view maintenance for materialized views")},
|
||||
{Name: "hypopg", Slug: "hypopg", ExtName: "hypopg", Description: s.t.Get("Hypothetical indexes for query plan testing")},
|
||||
{Name: "pgmq", Slug: "pgmq", ExtName: "pgmq", Description: s.t.Get("Lightweight message queue")},
|
||||
{Name: "orafce", Slug: "orafce", ExtName: "orafce", Description: s.t.Get("Oracle compatibility functions")},
|
||||
{Name: "http", Slug: "http", ExtName: "http", Description: s.t.Get("HTTP client for SQL, send requests from the database")},
|
||||
}
|
||||
}
|
||||
|
||||
// checkExtension 检查 slug 是否有效
|
||||
func (s *App) checkExtension(slug string) bool {
|
||||
return lo.ContainsBy(s.getExtensions(), func(e Extension) bool {
|
||||
return e.Slug == slug
|
||||
})
|
||||
}
|
||||
|
||||
// connect 以 postgres 超级用户连接指定数据库,默认 postgres 库
|
||||
func (s *App) connect(ctx context.Context, database ...string) (db.Operator, error) {
|
||||
password, err := s.settingRepo.Get(biz.SettingKeyPostgresPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db.NewPostgres(ctx, "postgres", password, "127.0.0.1", db.PostgresPort(app.Root), database...)
|
||||
}
|
||||
|
||||
func (s *App) configPath() string {
|
||||
return app.Root + "/server/postgresql/data/postgresql.conf"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,125 @@ type SetPostgresPassword struct {
|
||||
Password string `form:"password" json:"password" validate:"required && password"`
|
||||
}
|
||||
|
||||
// Extension PostgreSQL 扩展信息
|
||||
type Extension struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"` // 下载脚本名,如 pgvector
|
||||
ExtName string `json:"ext_name"` // CREATE EXTENSION 使用的扩展名,如 vector
|
||||
Description string `json:"description"`
|
||||
Installed bool `json:"installed"`
|
||||
InstalledVersion string `json:"installed_version"` // control 文件中的 default_version,仅展示
|
||||
}
|
||||
|
||||
// ExtensionSlug 扩展操作请求
|
||||
type ExtensionSlug struct {
|
||||
Slug string `form:"slug" json:"slug" validate:"required"`
|
||||
}
|
||||
|
||||
// ExtensionEnable 在指定数据库启用扩展请求
|
||||
type ExtensionEnable struct {
|
||||
Slug string `form:"slug" json:"slug" validate:"required"`
|
||||
Database string `form:"database" json:"database" validate:"required"`
|
||||
}
|
||||
|
||||
// Session 数据库会话信息
|
||||
type Session struct {
|
||||
PID int64 `json:"pid"`
|
||||
Database string `json:"database"`
|
||||
User string `json:"user"`
|
||||
ClientAddr string `json:"client_addr"`
|
||||
State string `json:"state"`
|
||||
WaitEventType string `json:"wait_event_type"`
|
||||
WaitEvent string `json:"wait_event"`
|
||||
BlockedBy string `json:"blocked_by"`
|
||||
XactSeconds int64 `json:"xact_seconds"`
|
||||
QuerySeconds int64 `json:"query_seconds"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// TopSQLItem Top SQL 统计项
|
||||
type TopSQLItem struct {
|
||||
Database string `json:"database"`
|
||||
Calls int64 `json:"calls"`
|
||||
TotalMs int64 `json:"total_ms"`
|
||||
MeanMs float64 `json:"mean_ms"`
|
||||
Rows int64 `json:"rows"`
|
||||
HitRate float64 `json:"hit_rate"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// TopSQL Top SQL 响应
|
||||
type TopSQL struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PendingRestart bool `json:"pending_restart"` // 已配置 preload,等待重启生效
|
||||
Items []TopSQLItem `json:"items"`
|
||||
}
|
||||
|
||||
// BloatQuery 表膨胀查询请求
|
||||
type BloatQuery struct {
|
||||
Database string `form:"database" json:"database" validate:"required"`
|
||||
}
|
||||
|
||||
// BloatItem 表膨胀信息
|
||||
type BloatItem struct {
|
||||
Schema string `json:"schema"`
|
||||
Table string `json:"table"`
|
||||
Size string `json:"size"`
|
||||
LiveTuples int64 `json:"live_tuples"`
|
||||
DeadTuples int64 `json:"dead_tuples"`
|
||||
DeadRate float64 `json:"dead_rate"`
|
||||
LastVacuum string `json:"last_vacuum"`
|
||||
LastAutovacuum string `json:"last_autovacuum"`
|
||||
LastAnalyze string `json:"last_analyze"`
|
||||
LastAutoanalyz string `json:"last_autoanalyze"`
|
||||
}
|
||||
|
||||
// Bloat 表膨胀响应
|
||||
type Bloat struct {
|
||||
RepackInstalled bool `json:"repack_installed"`
|
||||
Items []BloatItem `json:"items"`
|
||||
}
|
||||
|
||||
// MaintenanceRun 表维护操作请求
|
||||
type MaintenanceRun struct {
|
||||
Database string `form:"database" json:"database" validate:"required"`
|
||||
Schema string `form:"schema" json:"schema" validate:"required"`
|
||||
Table string `form:"table" json:"table" validate:"required"`
|
||||
Operation string `form:"operation" json:"operation" validate:"required"`
|
||||
}
|
||||
|
||||
// ReplicationSlot 复制槽信息
|
||||
type ReplicationSlot struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Active bool `json:"active"`
|
||||
RetainedWal string `json:"retained_wal"`
|
||||
}
|
||||
|
||||
// Replication 流复制连接信息
|
||||
type Replication struct {
|
||||
ClientAddr string `json:"client_addr"`
|
||||
State string `json:"state"`
|
||||
SyncState string `json:"sync_state"`
|
||||
Lag string `json:"lag"`
|
||||
}
|
||||
|
||||
// WalArchiver WAL 归档统计
|
||||
type WalArchiver struct {
|
||||
ArchivedCount int64 `json:"archived_count"`
|
||||
FailedCount int64 `json:"failed_count"`
|
||||
LastArchivedWal string `json:"last_archived_wal"`
|
||||
LastFailedWal string `json:"last_failed_wal"`
|
||||
}
|
||||
|
||||
// Wal WAL 状态响应
|
||||
type Wal struct {
|
||||
WalSize string `json:"wal_size"`
|
||||
Archiver WalArchiver `json:"archiver"`
|
||||
Slots []ReplicationSlot `json:"slots"`
|
||||
Replications []Replication `json:"replications"`
|
||||
}
|
||||
|
||||
// ConfigTune PostgreSQL 配置调整
|
||||
type ConfigTune struct {
|
||||
// 连接设置
|
||||
|
||||
+264
-3
@@ -1,41 +1,50 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
redigo "github.com/gomodule/redigo/redis"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/app"
|
||||
"github.com/acepanel/panel/v3/internal/apps/common"
|
||||
"github.com/acepanel/panel/v3/internal/apps/confval"
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
"github.com/acepanel/panel/v3/internal/service"
|
||||
"github.com/acepanel/panel/v3/pkg/db"
|
||||
"github.com/acepanel/panel/v3/pkg/io"
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
"github.com/acepanel/panel/v3/pkg/systemctl"
|
||||
"github.com/acepanel/panel/v3/pkg/tools"
|
||||
"github.com/acepanel/panel/v3/pkg/types"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
t *gotext.Locale
|
||||
databaseServerRepo biz.DatabaseServerRepo
|
||||
taskRepo biz.TaskRepo
|
||||
slug string // 服务名与配置目录名,如 redis、valkey
|
||||
name string // 展示名,如 Redis、Valkey
|
||||
}
|
||||
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
|
||||
return New("redis", "Redis", t, databaseServerRepo)
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
|
||||
return New("redis", "Redis", t, databaseServerRepo, taskRepo)
|
||||
}
|
||||
|
||||
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
|
||||
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
|
||||
return &App{
|
||||
t: t,
|
||||
databaseServerRepo: databaseServerRepo,
|
||||
taskRepo: taskRepo,
|
||||
slug: slug,
|
||||
name: name,
|
||||
}
|
||||
@@ -47,6 +56,13 @@ func (s *App) Route(r chi.Router) {
|
||||
r.Post("/config", s.UpdateConfig)
|
||||
r.Get("/config_tune", s.GetConfigTune)
|
||||
r.Post("/config_tune", s.UpdateConfigTune)
|
||||
// 性能诊断
|
||||
r.Get("/slow_log", s.SlowLog)
|
||||
r.Post("/slow_log/reset", s.ResetSlowLog)
|
||||
r.Get("/clients", s.ClientList)
|
||||
r.Post("/clients/kill", s.KillClient)
|
||||
r.Get("/memory", s.MemoryStatus)
|
||||
r.Post("/bigkeys", s.ScanBigKeys)
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
@@ -185,6 +201,251 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// SlowLog 获取慢日志
|
||||
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reply, err := conn.Exec("SLOWLOG", "GET", 50)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
rows, err := redigo.Values(reply, nil)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]SlowLogEntry, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item, itemErr := redigo.Values(row, nil)
|
||||
if itemErr != nil || len(item) < 4 {
|
||||
continue
|
||||
}
|
||||
entry := SlowLogEntry{}
|
||||
entry.ID, _ = redigo.Int64(item[0], nil)
|
||||
if ts, tsErr := redigo.Int64(item[1], nil); tsErr == nil {
|
||||
entry.Time = time.Unix(ts, 0).Format(time.DateTime)
|
||||
}
|
||||
entry.DurationUs, _ = redigo.Int64(item[2], nil)
|
||||
if cmd, cmdErr := redigo.Strings(item[3], nil); cmdErr == nil {
|
||||
entry.Command = strings.Join(cmd, " ")
|
||||
}
|
||||
if len(item) > 4 {
|
||||
entry.Client, _ = redigo.String(item[4], nil)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
service.Success(w, entries)
|
||||
}
|
||||
|
||||
// ResetSlowLog 重置慢日志
|
||||
func (s *App) ResetSlowLog(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err = conn.Exec("SLOWLOG", "RESET"); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// ClientList 获取客户端连接列表
|
||||
func (s *App) ClientList(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
raw, err := redigo.String(conn.Exec("CLIENT", "LIST"))
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
clients := make([]Client, 0)
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for kv := range strings.FieldsSeq(line) {
|
||||
if key, value, found := strings.Cut(kv, "="); found {
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
clients = append(clients, Client{
|
||||
ID: fields["id"],
|
||||
Addr: fields["addr"],
|
||||
Name: fields["name"],
|
||||
DB: fields["db"],
|
||||
Age: fields["age"],
|
||||
Idle: fields["idle"],
|
||||
Cmd: fields["cmd"],
|
||||
})
|
||||
}
|
||||
|
||||
service.Success(w, clients)
|
||||
}
|
||||
|
||||
// KillClient 踢除客户端连接
|
||||
func (s *App) KillClient(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[ClientKill](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
killed, err := redigo.Int64(conn.Exec("CLIENT", "KILL", "ID", req.ID))
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if killed == 0 {
|
||||
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("client %d not found", req.ID))
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// MemoryStatus 获取内存诊断信息
|
||||
func (s *App) MemoryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.connect(r.Context())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
memory := Memory{Items: make([]types.NV, 0)}
|
||||
memory.Doctor, err = redigo.String(conn.Exec("MEMORY", "DOCTOR"))
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
// 空库与无问题时返回的是电影梗彩蛋文案,无展示价值
|
||||
if strings.HasPrefix(memory.Doctor, "Hi Sam,") || strings.Contains(memory.Doctor, "can't find any memory issue") {
|
||||
memory.Doctor = ""
|
||||
}
|
||||
|
||||
reply, err := conn.Exec("MEMORY", "STATS")
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
values, err := redigo.Values(reply, nil)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
stats := map[string]string{}
|
||||
for i := 0; i+1 < len(values); i += 2 {
|
||||
key, keyErr := redigo.String(values[i], nil)
|
||||
if keyErr != nil {
|
||||
continue
|
||||
}
|
||||
// 值可能为整数或字符串,嵌套数组(如 db.0)跳过
|
||||
if v, vErr := redigo.Int64(values[i+1], nil); vErr == nil {
|
||||
stats[key] = cast.ToString(v)
|
||||
continue
|
||||
}
|
||||
if v, vErr := redigo.String(values[i+1], nil); vErr == nil {
|
||||
stats[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 各版本键名存在差异,仅展示存在的指标
|
||||
items := []struct {
|
||||
key string
|
||||
name string
|
||||
bytes bool
|
||||
}{
|
||||
{"peak.allocated", s.t.Get("Peak Allocated"), true},
|
||||
{"total.allocated", s.t.Get("Total Allocated"), true},
|
||||
{"startup.allocated", s.t.Get("Startup Allocated"), true},
|
||||
{"dataset.bytes", s.t.Get("Dataset Size"), true},
|
||||
{"dataset.percentage", s.t.Get("Dataset Percentage"), false},
|
||||
{"keys.count", s.t.Get("Keys Count"), false},
|
||||
{"keys.bytes-per-key", s.t.Get("Bytes Per Key"), true},
|
||||
{"allocator-fragmentation.ratio", s.t.Get("Allocator Fragmentation Ratio"), false},
|
||||
{"fragmentation", s.t.Get("Fragmentation Ratio"), false},
|
||||
}
|
||||
for _, item := range items {
|
||||
value, ok := stats[item.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if item.bytes {
|
||||
value = tools.FormatBytes(cast.ToFloat64(value))
|
||||
}
|
||||
memory.Items = append(memory.Items, types.NV{Name: item.name, Value: value})
|
||||
}
|
||||
|
||||
service.Success(w, memory)
|
||||
}
|
||||
|
||||
// ScanBigKeys 扫描大 Key(异步任务)
|
||||
func (s *App) ScanBigKeys(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := io.Read(s.confPath())
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
withPassword := ""
|
||||
if password := confval.Directive.Get(config, "requirepass"); password != "" {
|
||||
withPassword = " -a " + password
|
||||
}
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = s.slug + ":bigkeys"
|
||||
task.Name = s.t.Get("Scan %s big keys", s.name)
|
||||
task.Status = biz.TaskStatusWaiting
|
||||
task.Shell = fmt.Sprintf("%s-cli%s --bigkeys", s.slug, withPassword)
|
||||
if err = s.taskRepo.Push(task); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// connect 从配置文件读取端口与密码建立连接
|
||||
func (s *App) connect(ctx context.Context) (*db.Redis, error) {
|
||||
config, err := io.Read(s.confPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port := confval.Directive.Get(config, "port")
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
password := confval.Directive.Get(config, "requirepass")
|
||||
|
||||
return db.NewRedis(ctx, "", password, "127.0.0.1:"+port)
|
||||
}
|
||||
|
||||
func (s *App) confPath() string {
|
||||
return filepath.Join(app.Root, "server", s.slug, s.slug+".conf")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package redis
|
||||
|
||||
import "github.com/acepanel/panel/v3/pkg/types"
|
||||
|
||||
// ConfigTune Redis 协议兼容服务的配置调整
|
||||
type ConfigTune struct {
|
||||
// 常规设置
|
||||
@@ -16,3 +18,34 @@ type ConfigTune struct {
|
||||
Appendonly string `form:"appendonly" json:"appendonly" validate:"in:yes,no"`
|
||||
Appendfsync string `form:"appendfsync" json:"appendfsync" validate:"in:always,everysec,no"`
|
||||
}
|
||||
|
||||
// ClientKill 踢除客户端连接请求
|
||||
type ClientKill struct {
|
||||
ID int64 `form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// SlowLogEntry 慢日志条目
|
||||
type SlowLogEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time string `json:"time"`
|
||||
DurationUs int64 `json:"duration_us"`
|
||||
Command string `json:"command"`
|
||||
Client string `json:"client"`
|
||||
}
|
||||
|
||||
// Client 客户端连接信息
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Addr string `json:"addr"`
|
||||
Name string `json:"name"`
|
||||
DB string `json:"db"`
|
||||
Age string `json:"age"`
|
||||
Idle string `json:"idle"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
// Memory 内存诊断信息
|
||||
type Memory struct {
|
||||
Doctor string `json:"doctor"`
|
||||
Items []types.NV `json:"items"`
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ type App struct {
|
||||
redis *redis.App
|
||||
}
|
||||
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
|
||||
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
|
||||
return &App{
|
||||
redis: redis.New("valkey", "Valkey", t, databaseServerRepo),
|
||||
redis: redis.New("valkey", "Valkey", t, databaseServerRepo, taskRepo),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,10 +465,8 @@ func (r *websiteRepo) Create(req *request.WebsiteCreate) (*biz.Website, error) {
|
||||
if webServer == "apache" {
|
||||
spaConfig = apacheSPAConfig
|
||||
}
|
||||
if spaConfig != "" {
|
||||
if err = vhost.SetRawConfig("799-spa.conf", webservertypes.ScopeSite, spaConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = vhost.SetRawConfig("800-spa.conf", webservertypes.ScopeSite, spaConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,7 +745,7 @@ func (r *websiteRepo) SwitchType(req *request.WebsiteSwitchType) (*biz.Website,
|
||||
if webServer == "apache" {
|
||||
spaConfig = apacheSPAConfig
|
||||
}
|
||||
err = vhost.SetRawConfig("799-spa.conf", webservertypes.ScopeSite, spaConfig)
|
||||
err = vhost.SetRawConfig("800-spa.conf", webservertypes.ScopeSite, spaConfig)
|
||||
}
|
||||
if err != nil {
|
||||
return restore(err)
|
||||
@@ -1193,7 +1191,7 @@ func (r *websiteRepo) ResetConfig(id uint) error {
|
||||
if webServer == "apache" {
|
||||
spaConfig = apacheSPAConfig
|
||||
}
|
||||
err = vhost.SetRawConfig("799-spa.conf", webservertypes.ScopeSite, spaConfig)
|
||||
err = vhost.SetRawConfig("800-spa.conf", webservertypes.ScopeSite, spaConfig)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1482,6 +1480,9 @@ func (r *websiteRepo) readBasicAuthUsers(siteName string) map[string]string {
|
||||
users[parts[0]] = strings.TrimPrefix(parts[1], "{PLAIN}")
|
||||
}
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
return make(map[string]string)
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ type EnvironmentPHPUpdateConfig struct {
|
||||
Config string `form:"config" json:"config" validate:"required"`
|
||||
}
|
||||
|
||||
// EnvironmentPHPComposerMirror Composer 镜像源设置
|
||||
type EnvironmentPHPComposerMirror struct {
|
||||
Version uint `json:"version"`
|
||||
Mirror string `form:"mirror" json:"mirror"`
|
||||
}
|
||||
|
||||
// EnvironmentPHPConfigTune PHP 配置调整
|
||||
type EnvironmentPHPConfigTune struct {
|
||||
Version uint `json:"version"`
|
||||
|
||||
@@ -29,12 +29,16 @@ type FileTail struct {
|
||||
Offset int `json:"offset" form:"offset"`
|
||||
Limit int `json:"limit" form:"limit"`
|
||||
Cursor string `json:"cursor" form:"cursor"`
|
||||
// Size 为首屏返回的文件大小,翻页时回传作为反向分页锚点,避免日志持续写入导致错位
|
||||
Size int64 `json:"size" form:"size"`
|
||||
}
|
||||
|
||||
type FileFollow struct {
|
||||
Path string `json:"path" form:"path"`
|
||||
Service string `json:"service" form:"service"`
|
||||
Container string `json:"container" form:"container"`
|
||||
// Offset 为首屏锚点字节位置,从该处开始跟踪以衔接首屏与实时流,避免中间写入的日志丢失
|
||||
Offset int64 `json:"offset" form:"offset"`
|
||||
}
|
||||
|
||||
type FileCreate struct {
|
||||
|
||||
@@ -85,6 +85,18 @@ func EnvironmentRoutes(environmentDotnetService *service.EnvironmentDotnetServic
|
||||
Summary: "更新 PHP 配置调优", Tags: tags, Request: request.EnvironmentPHPConfigTune{}},
|
||||
{Method: http.MethodPost, Path: "/api/environment/php/{version}/clean_session", Handler: environmentPHP.CleanSession,
|
||||
Summary: "清理 PHP Session", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodGet, Path: "/api/environment/php/{version}/processes", Handler: environmentPHP.Processes,
|
||||
Summary: "获取 PHP-FPM 进程列表", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodGet, Path: "/api/environment/php/{version}/opcache", Handler: environmentPHP.Opcache,
|
||||
Summary: "获取 OPcache 状态", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodPost, Path: "/api/environment/php/{version}/opcache/reset", Handler: environmentPHP.ResetOpcache,
|
||||
Summary: "重置 OPcache", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodGet, Path: "/api/environment/php/{version}/composer", Handler: environmentPHP.Composer,
|
||||
Summary: "获取 Composer 状态", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodPost, Path: "/api/environment/php/{version}/composer/install", Handler: environmentPHP.InstallComposer,
|
||||
Summary: "安装 Composer", Tags: tags, Request: request.EnvironmentPHPVersion{}},
|
||||
{Method: http.MethodPost, Path: "/api/environment/php/{version}/composer/mirror", Handler: environmentPHP.SetComposerMirror,
|
||||
Summary: "设置 Composer 镜像源", Tags: tags, Request: request.EnvironmentPHPComposerMirror{}},
|
||||
|
||||
// Python
|
||||
{Method: http.MethodPost, Path: "/api/environment/python/{slug}/set_cli", Handler: environmentPython.SetCli,
|
||||
|
||||
+411
-152
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -19,8 +22,10 @@ import (
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
"github.com/acepanel/panel/v3/internal/request"
|
||||
"github.com/acepanel/panel/v3/pkg/config"
|
||||
"github.com/acepanel/panel/v3/pkg/fastcgi"
|
||||
"github.com/acepanel/panel/v3/pkg/io"
|
||||
"github.com/acepanel/panel/v3/pkg/shell"
|
||||
"github.com/acepanel/panel/v3/pkg/tools"
|
||||
"github.com/acepanel/panel/v3/pkg/types"
|
||||
)
|
||||
|
||||
@@ -355,6 +360,364 @@ func (s *EnvironmentPHPService) UninstallModule(w http.ResponseWriter, r *http.R
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// GetConfigTune 获取 PHP 配置调整参数
|
||||
func (s *EnvironmentPHPService) GetConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version)
|
||||
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
fpm, err := io.Read(fpmPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tune := request.EnvironmentPHPConfigTune{
|
||||
// php.ini 常规设置
|
||||
ShortOpenTag: confval.PHPINI.Get(ini, "short_open_tag"),
|
||||
DateTimezone: confval.PHPINI.Get(ini, "date.timezone"),
|
||||
DisplayErrors: confval.PHPINI.Get(ini, "display_errors"),
|
||||
ErrorReporting: confval.PHPINI.Get(ini, "error_reporting"),
|
||||
// php.ini 禁用函数
|
||||
DisableFunctions: confval.PHPINI.Get(ini, "disable_functions"),
|
||||
// php.ini 上传限制
|
||||
UploadMaxFilesize: confval.PHPINI.Get(ini, "upload_max_filesize"),
|
||||
PostMaxSize: confval.PHPINI.Get(ini, "post_max_size"),
|
||||
MaxFileUploads: confval.PHPINI.Get(ini, "max_file_uploads"),
|
||||
MemoryLimit: confval.PHPINI.Get(ini, "memory_limit"),
|
||||
// php.ini 超时限制
|
||||
MaxExecutionTime: confval.PHPINI.Get(ini, "max_execution_time"),
|
||||
MaxInputTime: confval.PHPINI.Get(ini, "max_input_time"),
|
||||
MaxInputVars: confval.PHPINI.Get(ini, "max_input_vars"),
|
||||
// Session 相关
|
||||
SessionSaveHandler: confval.PHPINI.Get(ini, "session.save_handler"),
|
||||
SessionSavePath: confval.PHPINI.Get(ini, "session.save_path"),
|
||||
SessionGcMaxlifetime: confval.PHPINI.Get(ini, "session.gc_maxlifetime"),
|
||||
SessionCookieLifetime: confval.PHPINI.Get(ini, "session.cookie_lifetime"),
|
||||
// php-fpm.conf 配置
|
||||
Pm: confval.PHPINI.Get(fpm, "pm"),
|
||||
PmMaxChildren: confval.PHPINI.Get(fpm, "pm.max_children"),
|
||||
PmStartServers: confval.PHPINI.Get(fpm, "pm.start_servers"),
|
||||
PmMinSpareServers: confval.PHPINI.Get(fpm, "pm.min_spare_servers"),
|
||||
PmMaxSpareServers: confval.PHPINI.Get(fpm, "pm.max_spare_servers"),
|
||||
}
|
||||
|
||||
Success(w, tune)
|
||||
}
|
||||
|
||||
// UpdateConfigTune 更新 PHP 配置调整参数
|
||||
func (s *EnvironmentPHPService) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPConfigTune](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version)
|
||||
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
fpm, err := io.Read(fpmPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 php.ini 配置
|
||||
ini = confval.PHPINI.Set(ini, "short_open_tag", req.ShortOpenTag)
|
||||
ini = confval.PHPINI.Set(ini, "date.timezone", req.DateTimezone)
|
||||
ini = confval.PHPINI.Set(ini, "display_errors", req.DisplayErrors)
|
||||
ini = confval.PHPINI.Set(ini, "error_reporting", req.ErrorReporting)
|
||||
ini = confval.PHPINI.Set(ini, "disable_functions", req.DisableFunctions)
|
||||
ini = confval.PHPINI.Set(ini, "upload_max_filesize", req.UploadMaxFilesize)
|
||||
ini = confval.PHPINI.Set(ini, "post_max_size", req.PostMaxSize)
|
||||
ini = confval.PHPINI.Set(ini, "max_execution_time", req.MaxExecutionTime)
|
||||
ini = confval.PHPINI.Set(ini, "max_input_time", req.MaxInputTime)
|
||||
ini = confval.PHPINI.Set(ini, "memory_limit", req.MemoryLimit)
|
||||
ini = confval.PHPINI.Set(ini, "max_input_vars", req.MaxInputVars)
|
||||
ini = confval.PHPINI.Set(ini, "max_file_uploads", req.MaxFileUploads)
|
||||
ini = confval.PHPINI.Set(ini, "session.save_handler", req.SessionSaveHandler)
|
||||
ini = confval.PHPINI.Set(ini, "session.save_path", req.SessionSavePath)
|
||||
ini = confval.PHPINI.Set(ini, "session.gc_maxlifetime", req.SessionGcMaxlifetime)
|
||||
ini = confval.PHPINI.Set(ini, "session.cookie_lifetime", req.SessionCookieLifetime)
|
||||
|
||||
// 更新 php-fpm.conf 配置
|
||||
fpm = confval.PHPINI.Set(fpm, "pm", req.Pm)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.max_children", req.PmMaxChildren)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.start_servers", req.PmStartServers)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.min_spare_servers", req.PmMinSpareServers)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.max_spare_servers", req.PmMaxSpareServers)
|
||||
|
||||
if err = io.Write(iniPath, ini, 0644); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if err = io.Write(fpmPath, fpm, 0644); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// CleanSession 清理 PHP Session 文件
|
||||
func (s *EnvironmentPHPService) CleanSession(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
handler := confval.PHPINI.Get(ini, "session.save_handler")
|
||||
if handler != "files" {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("Session save handler is not files, cannot clean"))
|
||||
return
|
||||
}
|
||||
|
||||
savePath := confval.PHPINI.Get(ini, "session.save_path")
|
||||
if savePath == "" {
|
||||
savePath = "/tmp"
|
||||
}
|
||||
|
||||
if _, err = shell.Execf("find '%s' -name 'sess_*' -type f -delete", savePath); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// Processes 获取 PHP-FPM 工作进程列表
|
||||
func (s *EnvironmentPHPService) Processes(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Processes []struct {
|
||||
PID int64 `json:"pid"`
|
||||
State string `json:"state"`
|
||||
StartSince int64 `json:"start since"`
|
||||
Requests int64 `json:"requests"`
|
||||
RequestDuration int64 `json:"request duration"`
|
||||
Method string `json:"request method"`
|
||||
URI string `json:"request uri"`
|
||||
Script string `json:"script"`
|
||||
LastCPU float64 `json:"last request cpu"`
|
||||
LastMemory int64 `json:"last request memory"`
|
||||
} `json:"processes"`
|
||||
}
|
||||
client := resty.New().SetTimeout(10 * time.Second)
|
||||
defer func(client *resty.Client) { _ = client.Close() }(client)
|
||||
if _, err = client.R().SetResult(&raw).Get(fmt.Sprintf("http://127.0.0.1/phpfpm_status/%d?json&full", req.Version)); err != nil {
|
||||
Success(w, []types.EnvironmentPHPProcess{})
|
||||
return
|
||||
}
|
||||
|
||||
processes := make([]types.EnvironmentPHPProcess, 0, len(raw.Processes))
|
||||
for _, item := range raw.Processes {
|
||||
processes = append(processes, types.EnvironmentPHPProcess{
|
||||
PID: item.PID,
|
||||
State: item.State,
|
||||
StartSince: item.StartSince,
|
||||
Requests: item.Requests,
|
||||
RequestDuration: item.RequestDuration,
|
||||
Method: item.Method,
|
||||
URI: item.URI,
|
||||
Script: item.Script,
|
||||
LastRequestCPU: item.LastCPU,
|
||||
LastRequestMem: item.LastMemory,
|
||||
})
|
||||
}
|
||||
|
||||
Success(w, processes)
|
||||
}
|
||||
|
||||
// Opcache 获取 OPcache 状态
|
||||
func (s *EnvironmentPHPService) Opcache(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := s.opcacheProbe(r.Context(), req.Version, "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to get OPcache status: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// OPcache 未启用时探针返回 false,无法解析为对象
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(body, &raw); err != nil || raw == nil {
|
||||
Success(w, types.EnvironmentPHPOpcache{Enabled: false})
|
||||
return
|
||||
}
|
||||
|
||||
memory := cast.ToStringMap(raw["memory_usage"])
|
||||
stats := cast.ToStringMap(raw["opcache_statistics"])
|
||||
jit := cast.ToStringMap(raw["jit"])
|
||||
|
||||
Success(w, types.EnvironmentPHPOpcache{
|
||||
Enabled: cast.ToBool(raw["opcache_enabled"]),
|
||||
MemoryUsed: tools.FormatBytes(cast.ToFloat64(memory["used_memory"])),
|
||||
MemoryFree: tools.FormatBytes(cast.ToFloat64(memory["free_memory"])),
|
||||
MemoryWasted: tools.FormatBytes(cast.ToFloat64(memory["wasted_memory"])),
|
||||
WastedPercent: math.Round(cast.ToFloat64(memory["current_wasted_percentage"])*100) / 100,
|
||||
HitRate: math.Round(cast.ToFloat64(stats["opcache_hit_rate"])*100) / 100,
|
||||
Hits: cast.ToInt64(stats["hits"]),
|
||||
Misses: cast.ToInt64(stats["misses"]),
|
||||
CachedScripts: cast.ToInt64(stats["num_cached_scripts"]),
|
||||
CachedKeys: cast.ToInt64(stats["num_cached_keys"]),
|
||||
MaxCachedKeys: cast.ToInt64(stats["max_cached_keys"]),
|
||||
OomRestarts: cast.ToInt64(stats["oom_restarts"]),
|
||||
JitEnabled: cast.ToBool(jit["enabled"]) && cast.ToBool(jit["on"]),
|
||||
JitBufferSize: tools.FormatBytes(cast.ToFloat64(jit["buffer_size"])),
|
||||
JitBufferFree: tools.FormatBytes(cast.ToFloat64(jit["buffer_free"])),
|
||||
})
|
||||
}
|
||||
|
||||
// ResetOpcache 重置 OPcache
|
||||
func (s *EnvironmentPHPService) ResetOpcache(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := s.opcacheProbe(r.Context(), req.Version, "action=reset")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to reset OPcache: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(body, &raw); err != nil || !cast.ToBool(raw["reset"]) {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("failed to reset OPcache, it may not be enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// Composer 获取 Composer 状态
|
||||
func (s *EnvironmentPHPService) Composer(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
composer := types.EnvironmentPHPComposer{Installed: io.Exists("/usr/local/bin/composer")}
|
||||
if composer.Installed {
|
||||
out, outErr := shell.ExecfWithEnv([]string{"COMPOSER_ALLOW_SUPERUSER=1"}, "%s/server/php/%d/bin/php /usr/local/bin/composer --version --no-ansi 2>/dev/null", app.Root, req.Version)
|
||||
// 输出形如 Composer version 2.8.4 2025-01-01 00:00:00
|
||||
if fields := strings.Fields(out); outErr == nil && len(fields) >= 3 {
|
||||
composer.Version = fields[2]
|
||||
}
|
||||
composer.Mirror = s.composerMirror()
|
||||
}
|
||||
|
||||
Success(w, composer)
|
||||
}
|
||||
|
||||
// InstallComposer 安装/更新 Composer(异步任务)
|
||||
func (s *EnvironmentPHPService) InstallComposer(w http.ResponseWriter, r *http.Request) {
|
||||
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/php/composer.sh' | bash -s -- 'install'`, s.conf.App.DownloadEndpoint)
|
||||
|
||||
task := new(biz.Task)
|
||||
task.Key = "php:composer"
|
||||
task.Name = s.t.Get("Install Composer")
|
||||
task.Status = biz.TaskStatusWaiting
|
||||
task.Shell = cmd
|
||||
if err := s.taskRepo.Push(task); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// SetComposerMirror 设置 Composer 全局镜像源
|
||||
func (s *EnvironmentPHPService) SetComposerMirror(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPComposerMirror](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !io.Exists("/usr/local/bin/composer") {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("Composer is not installed"))
|
||||
return
|
||||
}
|
||||
|
||||
php := fmt.Sprintf("%s/server/php/%d/bin/php", app.Root, req.Version)
|
||||
env := []string{"COMPOSER_ALLOW_SUPERUSER=1"}
|
||||
if req.Mirror == "" {
|
||||
// 恢复官方源,未设置过镜像时报错可忽略
|
||||
_, _ = shell.ExecfWithEnv(env, "%s /usr/local/bin/composer config -g --unset repos.packagist", php)
|
||||
Success(w, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(req.Mirror, "https://") && !strings.HasPrefix(req.Mirror, "http://") {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid mirror url"))
|
||||
return
|
||||
}
|
||||
if _, err = shell.ExecfWithEnv(env, "%s /usr/local/bin/composer config -g repos.packagist composer '%s'", php, req.Mirror); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
func (s *EnvironmentPHPService) getModules(version uint) []types.EnvironmentPHPModule {
|
||||
modules := []types.EnvironmentPHPModule{
|
||||
{
|
||||
@@ -602,162 +965,58 @@ func (s *EnvironmentPHPService) checkModule(version uint, slug string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfigTune 获取 PHP 配置调整参数
|
||||
func (s *EnvironmentPHPService) GetConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
// opcacheProbe 通过 FastCGI 请求 PHP-FPM 执行 OPcache 探针脚本
|
||||
func (s *EnvironmentPHPService) opcacheProbe(ctx context.Context, version uint, query string) ([]byte, error) {
|
||||
probePath := "/tmp/acepanel_opcache_probe.php"
|
||||
probe := `<?php
|
||||
if (($_GET['action'] ?? '') === 'reset') {
|
||||
echo json_encode(['reset' => function_exists('opcache_reset') && opcache_reset()]);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(function_exists('opcache_get_status') ? opcache_get_status(false) : false);
|
||||
`
|
||||
// FPM 以 www 用户执行,探针放在 /tmp 保证可读,每次覆盖写入保证内容正确
|
||||
if err := io.Write(probePath, probe, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version)
|
||||
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
fpm, err := io.Read(fpmPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tune := request.EnvironmentPHPConfigTune{
|
||||
// php.ini 常规设置
|
||||
ShortOpenTag: confval.PHPINI.Get(ini, "short_open_tag"),
|
||||
DateTimezone: confval.PHPINI.Get(ini, "date.timezone"),
|
||||
DisplayErrors: confval.PHPINI.Get(ini, "display_errors"),
|
||||
ErrorReporting: confval.PHPINI.Get(ini, "error_reporting"),
|
||||
// php.ini 禁用函数
|
||||
DisableFunctions: confval.PHPINI.Get(ini, "disable_functions"),
|
||||
// php.ini 上传限制
|
||||
UploadMaxFilesize: confval.PHPINI.Get(ini, "upload_max_filesize"),
|
||||
PostMaxSize: confval.PHPINI.Get(ini, "post_max_size"),
|
||||
MaxFileUploads: confval.PHPINI.Get(ini, "max_file_uploads"),
|
||||
MemoryLimit: confval.PHPINI.Get(ini, "memory_limit"),
|
||||
// php.ini 超时限制
|
||||
MaxExecutionTime: confval.PHPINI.Get(ini, "max_execution_time"),
|
||||
MaxInputTime: confval.PHPINI.Get(ini, "max_input_time"),
|
||||
MaxInputVars: confval.PHPINI.Get(ini, "max_input_vars"),
|
||||
// Session 相关
|
||||
SessionSaveHandler: confval.PHPINI.Get(ini, "session.save_handler"),
|
||||
SessionSavePath: confval.PHPINI.Get(ini, "session.save_path"),
|
||||
SessionGcMaxlifetime: confval.PHPINI.Get(ini, "session.gc_maxlifetime"),
|
||||
SessionCookieLifetime: confval.PHPINI.Get(ini, "session.cookie_lifetime"),
|
||||
// php-fpm.conf 配置
|
||||
Pm: confval.PHPINI.Get(fpm, "pm"),
|
||||
PmMaxChildren: confval.PHPINI.Get(fpm, "pm.max_children"),
|
||||
PmStartServers: confval.PHPINI.Get(fpm, "pm.start_servers"),
|
||||
PmMinSpareServers: confval.PHPINI.Get(fpm, "pm.min_spare_servers"),
|
||||
PmMaxSpareServers: confval.PHPINI.Get(fpm, "pm.max_spare_servers"),
|
||||
}
|
||||
|
||||
Success(w, tune)
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return fastcgi.Request(timeoutCtx, "unix", fmt.Sprintf("/tmp/php-cgi-%d.sock", version), map[string]string{
|
||||
"SCRIPT_FILENAME": probePath,
|
||||
"SCRIPT_NAME": "/acepanel_opcache_probe.php",
|
||||
"REQUEST_METHOD": "GET",
|
||||
"QUERY_STRING": query,
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"GATEWAY_INTERFACE": "CGI/1.1",
|
||||
"REMOTE_ADDR": "127.0.0.1",
|
||||
"SERVER_ADDR": "127.0.0.1",
|
||||
"SERVER_PORT": "80",
|
||||
"SERVER_NAME": "localhost",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateConfigTune 更新 PHP 配置调整参数
|
||||
func (s *EnvironmentPHPService) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPConfigTune](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
// composerMirror 读取 Composer 全局镜像源配置,未设置时返回空
|
||||
func (s *EnvironmentPHPService) composerMirror() string {
|
||||
for _, path := range []string{"/root/.config/composer/config.json", "/root/.composer/config.json"} {
|
||||
content, err := io.Read(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cfg struct {
|
||||
Repositories map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"repositories"`
|
||||
}
|
||||
if json.Unmarshal([]byte(content), &cfg) != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"packagist", "packagist.org"} {
|
||||
if repo, ok := cfg.Repositories[key]; ok && repo.URL != "" {
|
||||
return repo.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version)
|
||||
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
fpm, err := io.Read(fpmPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 php.ini 配置
|
||||
ini = confval.PHPINI.Set(ini, "short_open_tag", req.ShortOpenTag)
|
||||
ini = confval.PHPINI.Set(ini, "date.timezone", req.DateTimezone)
|
||||
ini = confval.PHPINI.Set(ini, "display_errors", req.DisplayErrors)
|
||||
ini = confval.PHPINI.Set(ini, "error_reporting", req.ErrorReporting)
|
||||
ini = confval.PHPINI.Set(ini, "disable_functions", req.DisableFunctions)
|
||||
ini = confval.PHPINI.Set(ini, "upload_max_filesize", req.UploadMaxFilesize)
|
||||
ini = confval.PHPINI.Set(ini, "post_max_size", req.PostMaxSize)
|
||||
ini = confval.PHPINI.Set(ini, "max_execution_time", req.MaxExecutionTime)
|
||||
ini = confval.PHPINI.Set(ini, "max_input_time", req.MaxInputTime)
|
||||
ini = confval.PHPINI.Set(ini, "memory_limit", req.MemoryLimit)
|
||||
ini = confval.PHPINI.Set(ini, "max_input_vars", req.MaxInputVars)
|
||||
ini = confval.PHPINI.Set(ini, "max_file_uploads", req.MaxFileUploads)
|
||||
ini = confval.PHPINI.Set(ini, "session.save_handler", req.SessionSaveHandler)
|
||||
ini = confval.PHPINI.Set(ini, "session.save_path", req.SessionSavePath)
|
||||
ini = confval.PHPINI.Set(ini, "session.gc_maxlifetime", req.SessionGcMaxlifetime)
|
||||
ini = confval.PHPINI.Set(ini, "session.cookie_lifetime", req.SessionCookieLifetime)
|
||||
|
||||
// 更新 php-fpm.conf 配置
|
||||
fpm = confval.PHPINI.Set(fpm, "pm", req.Pm)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.max_children", req.PmMaxChildren)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.start_servers", req.PmStartServers)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.min_spare_servers", req.PmMinSpareServers)
|
||||
fpm = confval.PHPINI.Set(fpm, "pm.max_spare_servers", req.PmMaxSpareServers)
|
||||
|
||||
if err = io.Write(iniPath, ini, 0644); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if err = io.Write(fpmPath, fpm, 0644); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
// CleanSession 清理 PHP Session 文件
|
||||
func (s *EnvironmentPHPService) CleanSession(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.EnvironmentPHPVersion](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version))
|
||||
return
|
||||
}
|
||||
|
||||
iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version)
|
||||
ini, err := io.Read(iniPath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
handler := confval.PHPINI.Get(ini, "session.save_handler")
|
||||
if handler != "files" {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("Session save handler is not files, cannot clean"))
|
||||
return
|
||||
}
|
||||
|
||||
savePath := confval.PHPINI.Get(ini, "session.save_path")
|
||||
if savePath == "" {
|
||||
savePath = "/tmp"
|
||||
}
|
||||
|
||||
if _, err = shell.Execf("find '%s' -name 'sess_*' -type f -delete", savePath); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
return ""
|
||||
}
|
||||
|
||||
+27
-19
@@ -1,9 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -130,9 +129,8 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Limit > 5000 {
|
||||
req.Limit = 5000
|
||||
}
|
||||
if req.Offset < 0 {
|
||||
req.Offset = 0
|
||||
}
|
||||
// 限制回溯深度,三种日志源共用;再深的历史交给下载原始日志
|
||||
req.Offset = min(max(req.Offset, 0), 10000)
|
||||
|
||||
if req.Path == "" && req.Service == "" && req.Container == "" {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("path, service or container is required"))
|
||||
@@ -166,27 +164,36 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
|
||||
Success(w, chix.M{"lines": []string{}, "has_more": false, "size": size})
|
||||
return
|
||||
}
|
||||
// 以首屏返回的大小为锚点反向分页,否则跟踪期间写入的新行会顶掉偏移量导致翻页重复
|
||||
anchor := size
|
||||
if req.Size > 0 {
|
||||
anchor = min(req.Size, size)
|
||||
}
|
||||
|
||||
// 从尾部反向读取,直到攒够 offset+limit+1 个换行符(多 1 是为了避免读到不完整的首行)
|
||||
const chunkSize = int64(8192)
|
||||
pos := size
|
||||
var data []byte
|
||||
// 从锚点反向读取,直到攒够 offset+limit+1 个换行符(多 1 是为了避免读到不完整的首行)
|
||||
// 首块按预估行长一次读足,绝大多数请求一两次系统调用即可完成;maxScan 兜住超长行
|
||||
const maxScan = int64(64 << 20)
|
||||
needLines := req.Offset + req.Limit + 1
|
||||
readSize := min(int64(needLines)*256, maxScan)
|
||||
pos := anchor
|
||||
chunks := make([][]byte, 0, 4)
|
||||
newlineCount := 0
|
||||
for pos > 0 && newlineCount < needLines {
|
||||
readSize := min(chunkSize, pos)
|
||||
for pos > 0 && newlineCount < needLines && anchor-pos < maxScan {
|
||||
readSize = min(readSize, pos)
|
||||
pos -= readSize
|
||||
buf := make([]byte, readSize)
|
||||
if _, rerr := f.ReadAt(buf, pos); rerr != nil && rerr != stdio.EOF {
|
||||
Error(w, http.StatusInternalServerError, "%v", rerr)
|
||||
return
|
||||
}
|
||||
data = append(buf, data...)
|
||||
newlineCount = strings.Count(string(data), "\n")
|
||||
newlineCount += bytes.Count(buf, []byte{'\n'})
|
||||
chunks = append(chunks, buf)
|
||||
}
|
||||
slices.Reverse(chunks)
|
||||
data := bytes.Join(chunks, nil)
|
||||
|
||||
// 切分行
|
||||
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// 按字节切分,只把真正返回的那一页转成 string,避免整个扫描窗口再复制一份
|
||||
all := bytes.Split(bytes.TrimRight(data, "\n"), []byte{'\n'})
|
||||
totalLoaded := len(all)
|
||||
|
||||
// 当 pos > 0 时第一行可能不完整,丢弃以避免半行被显示
|
||||
@@ -206,9 +213,9 @@ func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hasMore := pos > 0 || startIdx > startBoundary
|
||||
|
||||
result := []string{}
|
||||
if startIdx < endIdx {
|
||||
result = all[startIdx:endIdx]
|
||||
result := make([]string, 0, max(endIdx-startIdx, 0))
|
||||
for _, line := range all[startIdx:endIdx] {
|
||||
result = append(result, string(line))
|
||||
}
|
||||
|
||||
Success(w, chix.M{
|
||||
@@ -997,7 +1004,7 @@ func (s *FileService) tailService(w http.ResponseWriter, req *request.FileTail)
|
||||
lines = append(lines, formatJournalLine(e.Timestamp, e.Hostname, e.Ident, e.Comm, e.PID, e.Message))
|
||||
}
|
||||
|
||||
// next_cursor 是本次结果中最早那条的 cursor,供下一页 --after-cursor + --reverse 使用
|
||||
// next_cursor 是本次结果中最早那条的 cursor,供下一页 --before-cursor 继续往前翻
|
||||
nextCursor := ""
|
||||
if len(entries) > 0 {
|
||||
nextCursor = entries[0].Cursor
|
||||
@@ -1041,6 +1048,7 @@ func formatJournalLine(ts, hostname, ident, comm, pid, message string) string {
|
||||
|
||||
// tailContainer 反向读取容器末尾日志
|
||||
func (s *FileService) tailContainer(w http.ResponseWriter, req *request.FileTail) {
|
||||
// 容器日志只能整段拉取再切片,回溯深度由 Tail 顶部统一钳制
|
||||
total := req.Offset + req.Limit
|
||||
out, err := s.containerRepo.Logs(req.Container, total)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
// 这个文件只是为了在 Windows 下能编译通过,实际上并没有任何卵用
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
|
||||
"github.com/acepanel/panel/v3/internal/biz"
|
||||
)
|
||||
|
||||
type FileService struct {
|
||||
t *gotext.Locale
|
||||
taskRepo *biz.TaskUsecase
|
||||
containerRepo *biz.ContainerUsecase
|
||||
}
|
||||
|
||||
func NewFileService(containerUsecase *biz.ContainerUsecase, taskUsecase *biz.TaskUsecase, t *gotext.Locale) *FileService {
|
||||
return &FileService{
|
||||
t: t,
|
||||
taskRepo: taskUsecase,
|
||||
containerRepo: containerUsecase,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileService) Create(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Content(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Tail(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Save(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Exist(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Move(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Copy(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Download(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) RemoteDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *FileService) Info(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Permission(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) Compress(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) UnCompress(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (s *FileService) List(w http.ResponseWriter, r *http.Request) {}
|
||||
@@ -206,6 +206,11 @@ func (s *ToolboxMigrationService) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", scanner.Text())
|
||||
flusher.Flush()
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
_, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", scanErr.Error())
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
if waitErr := <-waitCh; waitErr != nil {
|
||||
_, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", waitErr.Error())
|
||||
|
||||
@@ -125,6 +125,9 @@ func (s *WsService) Follow(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd *exec.Cmd
|
||||
if req.Service != "" {
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "--no-pager", "-n", "0", "-f", "-u", req.Service)
|
||||
} else if req.Offset > 0 {
|
||||
// 从首屏锚点接着跟踪,补上首屏读取到建立连接之间写入的日志(-c 的字节偏移从 1 开始)
|
||||
cmd = exec.CommandContext(ctx, "tail", "-c", fmt.Sprintf("+%d", req.Offset+1), "-F", req.Path)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "tail", "-n", "0", "-F", req.Path)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build !windows
|
||||
|
||||
// Package chattr https://github.com/g0rbe/go-chattr/pull/3
|
||||
package chattr
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package chattr
|
||||
|
||||
/*
|
||||
A package for change attribute of a file on Linux, similar to the chattr command.
|
||||
|
||||
Example to set the immutable attribute to a file:
|
||||
|
||||
file, err := os.OpenFile("file.txt", os.O_RDONLY, 0666)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
err = chattr.SetAttr(file, chattr.FS_IMMUTABLE_FL)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
*/
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// from /usr/include/linux/fs.h
|
||||
const (
|
||||
FS_SECRM_FL uint32 = 0x00000001 /* Secure deletion */
|
||||
FS_UNRM_FL uint32 = 0x00000002 /* Undelete */
|
||||
FS_COMPR_FL uint32 = 0x00000004 /* Compress file */
|
||||
FS_SYNC_FL uint32 = 0x00000008 /* Synchronous updates */
|
||||
FS_IMMUTABLE_FL uint32 = 0x00000010 /* Immutable file */
|
||||
FS_APPEND_FL uint32 = 0x00000020 /* writes to file may only append */
|
||||
FS_NODUMP_FL uint32 = 0x00000040 /* do not dump file */
|
||||
FS_NOATIME_FL uint32 = 0x00000080 /* do not update atime */
|
||||
FS_DIRTY_FL uint32 = 0x00000100
|
||||
FS_COMPRBLK_FL uint32 = 0x00000200 /* One or more compressed clusters */
|
||||
FS_NOCOMP_FL uint32 = 0x00000400 /* Don't compress */
|
||||
FS_ENCRYPT_FL uint32 = 0x00000800 /* Encrypted file */
|
||||
FS_BTREE_FL uint32 = 0x00001000 /* btree format dir */
|
||||
FS_INDEX_FL uint32 = 0x00001000 /* hash-indexed directory */
|
||||
FS_IMAGIC_FL uint32 = 0x00002000 /* AFS directory */
|
||||
FS_JOURNAL_DATA_FL uint32 = 0x00004000 /* Reserved for ext3 */
|
||||
FS_NOTAIL_FL uint32 = 0x00008000 /* file tail should not be merged */
|
||||
FS_DIRSYNC_FL uint32 = 0x00010000 /* dirsync behaviour (directories only) */
|
||||
FS_TOPDIR_FL uint32 = 0x00020000 /* Top of directory hierarchies*/
|
||||
FS_HUGE_FILE_FL uint32 = 0x00040000 /* Reserved for ext4 */
|
||||
FS_EXTENT_FL uint32 = 0x00080000 /* Extents */
|
||||
FS_EA_INODE_FL uint32 = 0x00200000 /* Inode used for large EA */
|
||||
FS_EOFBLOCKS_FL uint32 = 0x00400000 /* Reserved for ext4 */
|
||||
FS_NOCOW_FL uint32 = 0x00800000 /* Do not cow file */
|
||||
FS_INLINE_DATA_FL uint32 = 0x10000000 /* Reserved for ext4 */
|
||||
FS_PROJINHERIT_FL uint32 = 0x20000000 /* Create with parents projid */
|
||||
FS_RESERVED_FL uint32 = 0x80000000 /* reserved for ext2 lib */
|
||||
)
|
||||
|
||||
// from ioctl_list manpage
|
||||
const (
|
||||
FS_IOC_GETFLAGS uintptr = 0x80086601
|
||||
FS_IOC_SETFLAGS uintptr = 0x40086602
|
||||
)
|
||||
|
||||
func ioctl(f *os.File, request uintptr, attrp *uint32) error {
|
||||
return errors.New("not supported on windows")
|
||||
}
|
||||
|
||||
// GetAttrs retrieves the attributes of a file.
|
||||
func GetAttrs(f *os.File) (uint32, error) {
|
||||
attr := uint32(1)
|
||||
err := ioctl(f, FS_IOC_GETFLAGS, &attr)
|
||||
|
||||
return attr, err
|
||||
}
|
||||
|
||||
// SetAttr sets the given attribute.
|
||||
func SetAttr(f *os.File, attr uint32) error {
|
||||
attrs, err := GetAttrs(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attrs |= attr
|
||||
|
||||
return ioctl(f, FS_IOC_SETFLAGS, &attrs)
|
||||
}
|
||||
|
||||
// UnsetAttr unsets the given attribute.
|
||||
func UnsetAttr(f *os.File, attr uint32) error {
|
||||
attrs, err := GetAttrs(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attrs ^= attrs & attr
|
||||
|
||||
return ioctl(f, FS_IOC_SETFLAGS, &attrs)
|
||||
}
|
||||
|
||||
// IsAttr checks whether the given attribute is set.
|
||||
func IsAttr(f *os.File, attr uint32) (bool, error) {
|
||||
attrs, err := GetAttrs(f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if (attrs & attr) != 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
+7
-3
@@ -18,16 +18,20 @@ type Postgres struct {
|
||||
port uint
|
||||
}
|
||||
|
||||
func NewPostgres(ctx context.Context, username, password, address string, port uint) (Operator, error) {
|
||||
func NewPostgres(ctx context.Context, username, password, address string, port uint, database ...string) (Operator, error) {
|
||||
username = strings.ReplaceAll(username, `'`, `\'`)
|
||||
password = strings.ReplaceAll(password, `'`, `\'`)
|
||||
dbname := "postgres"
|
||||
if len(database) > 0 && database[0] != "" {
|
||||
dbname = strings.ReplaceAll(database[0], `'`, `\'`)
|
||||
}
|
||||
// connect_timeout 限制建连耗时,避免不可达地址阻塞调用方
|
||||
dsn := fmt.Sprintf(`host=%s port=%d user='%s' password='%s' dbname=postgres sslmode=disable connect_timeout=5`, address, port, username, password)
|
||||
dsn := fmt.Sprintf(`host=%s port=%d user='%s' password='%s' dbname='%s' sslmode=disable connect_timeout=5`, address, port, username, password, dbname)
|
||||
if password == "" {
|
||||
if username == "" {
|
||||
username = "postgres"
|
||||
}
|
||||
dsn = fmt.Sprintf(`host=%s port=%d user='%s' dbname=postgres sslmode=disable connect_timeout=5`, address, port, username)
|
||||
dsn = fmt.Sprintf(`host=%s port=%d user='%s' dbname='%s' sslmode=disable connect_timeout=5`, address, port, username, dbname)
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
|
||||
+396
-206
File diff suppressed because it is too large
Load Diff
+397
-207
File diff suppressed because it is too large
Load Diff
+397
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package fastcgi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
const (
|
||||
typeBeginRequest byte = 1
|
||||
typeEndRequest byte = 3
|
||||
typeParams byte = 4
|
||||
typeStdin byte = 5
|
||||
typeStdout byte = 6
|
||||
typeStderr byte = 7
|
||||
|
||||
roleResponder = 1
|
||||
)
|
||||
|
||||
// Request 向 FastCGI 服务发起一次 responder 请求,返回剥离 CGI 响应头后的 body
|
||||
func Request(ctx context.Context, network, address string, params map[string]string) ([]byte, error) {
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
const requestID = 1
|
||||
// BEGIN_REQUEST:responder 角色,连接不复用
|
||||
if err = writeRecord(conn, typeBeginRequest, requestID, []byte{0, roleResponder, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for name, value := range params {
|
||||
encodeNameValue(&buf, name, value)
|
||||
}
|
||||
if err = writeRecord(conn, typeParams, requestID, buf.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writeRecord(conn, typeParams, requestID, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writeRecord(conn, typeStdin, requestID, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
header := make([]byte, 8)
|
||||
for {
|
||||
if _, err = io.ReadFull(conn, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentLength := binary.BigEndian.Uint16(header[4:6])
|
||||
content := make([]byte, int(contentLength)+int(header[6]))
|
||||
if _, err = io.ReadFull(conn, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch header[1] {
|
||||
case typeStdout:
|
||||
stdout.Write(content[:contentLength])
|
||||
case typeStderr:
|
||||
stderr.Write(content[:contentLength])
|
||||
case typeEndRequest:
|
||||
if stderr.Len() > 0 {
|
||||
return nil, fmt.Errorf("fastcgi stderr: %s", stderr.String())
|
||||
}
|
||||
// 剥离 CGI 响应头
|
||||
if _, body, found := bytes.Cut(stdout.Bytes(), []byte("\r\n\r\n")); found {
|
||||
return body, nil
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeRecord 写入一条 FastCGI 记录
|
||||
func writeRecord(w io.Writer, typ byte, requestID uint16, content []byte) error {
|
||||
header := [8]byte{1, typ}
|
||||
binary.BigEndian.PutUint16(header[2:4], requestID)
|
||||
binary.BigEndian.PutUint16(header[4:6], uint16(len(content))) //nolint:gosec // params 由面板构造,长度远小于 64KB
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(content) > 0 {
|
||||
if _, err := w.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeNameValue 按 FastCGI name-value 格式编码
|
||||
func encodeNameValue(buf *bytes.Buffer, name, value string) {
|
||||
writeLength := func(n int) {
|
||||
if n < 128 {
|
||||
buf.WriteByte(byte(n))
|
||||
return
|
||||
}
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], uint32(n)|1<<31) //nolint:gosec // 长度非负
|
||||
buf.Write(b[:])
|
||||
}
|
||||
writeLength(len(name))
|
||||
writeLength(len(value))
|
||||
buf.WriteString(name)
|
||||
buf.WriteString(value)
|
||||
}
|
||||
+5
-2
@@ -30,6 +30,9 @@ func readOSRelease() map[string]string {
|
||||
osRelease[key] = value
|
||||
}
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return osRelease
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ func IsEOL() bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
majorVersion := strings.Split(version, ".")[0]
|
||||
majorVersion, _, _ := strings.Cut(version, ".")
|
||||
if eol, ok := eolTimeTable["rhel"][majorVersion]; ok {
|
||||
return time.Now().After(eol)
|
||||
}
|
||||
@@ -111,7 +114,7 @@ func IsEOL() bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
majorVersion := strings.Split(version, ".")[0]
|
||||
majorVersion, _, _ := strings.Cut(version, ".")
|
||||
if eol, ok := eolTimeTable["debian"][majorVersion]; ok {
|
||||
return time.Now().After(eol)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,43 @@ type EnvironmentPHPModule struct {
|
||||
Description string `json:"description"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
// EnvironmentPHPProcess PHP-FPM 工作进程信息
|
||||
type EnvironmentPHPProcess struct {
|
||||
PID int64 `json:"pid"`
|
||||
State string `json:"state"`
|
||||
StartSince int64 `json:"start_since"`
|
||||
Requests int64 `json:"requests"`
|
||||
RequestDuration int64 `json:"request_duration"` // 微秒
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
Script string `json:"script"`
|
||||
LastRequestCPU float64 `json:"last_request_cpu"`
|
||||
LastRequestMem int64 `json:"last_request_memory"`
|
||||
}
|
||||
|
||||
// EnvironmentPHPOpcache OPcache 状态
|
||||
type EnvironmentPHPOpcache struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MemoryUsed string `json:"memory_used"`
|
||||
MemoryFree string `json:"memory_free"`
|
||||
MemoryWasted string `json:"memory_wasted"`
|
||||
WastedPercent float64 `json:"wasted_percent"`
|
||||
HitRate float64 `json:"hit_rate"`
|
||||
Hits int64 `json:"hits"`
|
||||
Misses int64 `json:"misses"`
|
||||
CachedScripts int64 `json:"cached_scripts"`
|
||||
CachedKeys int64 `json:"cached_keys"`
|
||||
MaxCachedKeys int64 `json:"max_cached_keys"`
|
||||
OomRestarts int64 `json:"oom_restarts"`
|
||||
JitEnabled bool `json:"jit_enabled"`
|
||||
JitBufferSize string `json:"jit_buffer_size"`
|
||||
JitBufferFree string `json:"jit_buffer_free"`
|
||||
}
|
||||
|
||||
// EnvironmentPHPComposer Composer 状态
|
||||
type EnvironmentPHPComposer struct {
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version"`
|
||||
Mirror string `json:"mirror"` // 空为官方源
|
||||
}
|
||||
|
||||
Generated
+20
-20
@@ -76,7 +76,7 @@ importers:
|
||||
version: 0.56.0
|
||||
monaco-editor-nginx:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2(@babel/runtime@8.0.0)(@nginx/reference-lib@1.1.38)(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
version: 2.0.2(@babel/runtime@8.0.0)(@nginx/reference-lib@1.1.39)(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
node-forge:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0
|
||||
@@ -656,15 +656,15 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.2':
|
||||
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==}
|
||||
'@napi-rs/wasm-runtime@1.2.3':
|
||||
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@nginx/reference-lib@1.1.38':
|
||||
resolution: {integrity: sha512-soN9SKdAkBZFd0e/qinoDhjfabXGRkJqYpRmHayTcJGks3RSJJKWgvvUI0tzklGYv6ySmzLU9VVnUjEWpdUrCA==}
|
||||
'@nginx/reference-lib@1.1.39':
|
||||
resolution: {integrity: sha512-Gmf8Ce7aOHKpK+KFvwDu87kXmcqCCOkTECT6055ZlPUgS+bL64kSZE/Y0ljO9oJcjKs2raHNV7YCByjcjVPmTw==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
@@ -1838,8 +1838,8 @@ packages:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.5.0:
|
||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||
default-browser@5.5.1:
|
||||
resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
@@ -2212,8 +2212,8 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-base64@3.9.2:
|
||||
resolution: {integrity: sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA==}
|
||||
js-base64@3.9.3:
|
||||
resolution: {integrity: sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==}
|
||||
|
||||
js-sha256@1.0.0:
|
||||
resolution: {integrity: sha512-Bqxf6ENUzYIMzuELCmRNrJOVbjKH1oMgbfYJBKVr/W1Xf9fazpahqCbb24v1pR7XV1isuqhM+w9KWpK7zCyUQw==}
|
||||
@@ -3701,14 +3701,14 @@ snapshots:
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@nginx/reference-lib@1.1.38': {}
|
||||
'@nginx/reference-lib@1.1.39': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
@@ -3774,7 +3774,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.131.0':
|
||||
@@ -4482,7 +4482,7 @@ snapshots:
|
||||
|
||||
'@xterm/addon-clipboard@0.2.0':
|
||||
dependencies:
|
||||
js-base64: 3.9.2
|
||||
js-base64: 3.9.3
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
@@ -4728,7 +4728,7 @@ snapshots:
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.5.0:
|
||||
default-browser@5.5.1:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
@@ -5104,7 +5104,7 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-base64@3.9.2: {}
|
||||
js-base64@3.9.3: {}
|
||||
|
||||
js-sha256@1.0.0: {}
|
||||
|
||||
@@ -5292,10 +5292,10 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
monaco-editor-nginx@2.0.2(@babel/runtime@8.0.0)(@nginx/reference-lib@1.1.38)(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
monaco-editor-nginx@2.0.2(@babel/runtime@8.0.0)(@nginx/reference-lib@1.1.39)(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@nginx/reference-lib': 1.1.38
|
||||
'@nginx/reference-lib': 1.1.39
|
||||
monaco-editor: 0.56.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
@@ -5395,7 +5395,7 @@ snapshots:
|
||||
|
||||
open@11.0.1:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
default-browser: 5.5.1
|
||||
define-lazy-prop: 3.0.0
|
||||
is-in-ssh: 1.0.0
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
@@ -11,4 +11,8 @@ export default {
|
||||
errorLog: (): any => http.Get('/apps/apache/error_log'),
|
||||
// 清空错误日志
|
||||
clearErrorLog: (): any => http.Post('/apps/apache/clear_error_log'),
|
||||
// 获取配置调整参数
|
||||
configTune: (): any => http.Get('/apps/apache/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/apache/config_tune', data),
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -22,4 +22,23 @@ export default {
|
||||
configTune: (): any => http.Get('/apps/postgresql/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/postgresql/config_tune', data),
|
||||
// 扩展管理
|
||||
extensions: (): any => http.Get('/apps/postgresql/extensions'),
|
||||
installExtension: (slug: string): any => http.Post('/apps/postgresql/extensions', { slug }),
|
||||
uninstallExtension: (slug: string): any => http.Delete('/apps/postgresql/extensions', { slug }),
|
||||
enableExtension: (slug: string, database: string): any =>
|
||||
http.Post('/apps/postgresql/extensions/enable', { slug, database }),
|
||||
// 性能
|
||||
sessions: (): any => http.Get('/apps/postgresql/sessions'),
|
||||
terminateSession: (pid: number): any => http.Post(`/apps/postgresql/sessions/${pid}/terminate`),
|
||||
topSQL: (): any => http.Get('/apps/postgresql/top_sql'),
|
||||
enableTopSQL: (): any => http.Post('/apps/postgresql/top_sql/enable'),
|
||||
resetTopSQL: (): any => http.Post('/apps/postgresql/top_sql/reset'),
|
||||
// 维护
|
||||
databases: (): any => http.Get('/apps/postgresql/databases'),
|
||||
bloat: (database: string): any => http.Get('/apps/postgresql/bloat', { params: { database } }),
|
||||
runMaintenance: (data: any): any => http.Post('/apps/postgresql/maintenance', data),
|
||||
wal: (): any => http.Get('/apps/postgresql/wal'),
|
||||
dropReplicationSlot: (slot: string): any =>
|
||||
http.Delete(`/apps/postgresql/replication_slots/${slot}`),
|
||||
}
|
||||
|
||||
@@ -11,4 +11,14 @@ export default {
|
||||
configTune: (): any => http.Get('/apps/redis/config_tune'),
|
||||
// 保存配置调整参数
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/redis/config_tune', data),
|
||||
// 慢日志
|
||||
slowLog: (): any => http.Get('/apps/redis/slow_log'),
|
||||
resetSlowLog: (): any => http.Post('/apps/redis/slow_log/reset'),
|
||||
// 客户端连接
|
||||
clients: (): any => http.Get('/apps/redis/clients'),
|
||||
killClient: (id: number): any => http.Post('/apps/redis/clients/kill', { id }),
|
||||
// 内存诊断
|
||||
memory: (): any => http.Get('/apps/redis/memory'),
|
||||
// 扫描大 Key
|
||||
scanBigKeys: (): any => http.Post('/apps/redis/bigkeys'),
|
||||
}
|
||||
|
||||
@@ -6,4 +6,14 @@ export default {
|
||||
saveConfig: (config: string): any => http.Post('/apps/valkey/config', { config }),
|
||||
configTune: (): any => http.Get('/apps/valkey/config_tune'),
|
||||
saveConfigTune: (data: any): any => http.Post('/apps/valkey/config_tune', data),
|
||||
// 慢日志
|
||||
slowLog: (): any => http.Get('/apps/valkey/slow_log'),
|
||||
resetSlowLog: (): any => http.Post('/apps/valkey/slow_log/reset'),
|
||||
// 客户端连接
|
||||
clients: (): any => http.Get('/apps/valkey/clients'),
|
||||
killClient: (id: number): any => http.Post('/apps/valkey/clients/kill', { id }),
|
||||
// 内存诊断
|
||||
memory: (): any => http.Get('/apps/valkey/memory'),
|
||||
// 扫描大 Key
|
||||
scanBigKeys: (): any => http.Post('/apps/valkey/bigkeys'),
|
||||
}
|
||||
|
||||
@@ -36,4 +36,17 @@ export default {
|
||||
http.Post(`/environment/php/${slug}/config_tune`, data),
|
||||
// 清理 Session 文件
|
||||
cleanSession: (slug: number): any => http.Post(`/environment/php/${slug}/clean_session`),
|
||||
// FPM 进程列表
|
||||
processes: (slug: number): any => http.Get(`/environment/php/${slug}/processes`),
|
||||
// OPcache 状态
|
||||
opcache: (slug: number): any => http.Get(`/environment/php/${slug}/opcache`),
|
||||
// 重置 OPcache
|
||||
resetOpcache: (slug: number): any => http.Post(`/environment/php/${slug}/opcache/reset`),
|
||||
// Composer 状态
|
||||
composer: (slug: number): any => http.Get(`/environment/php/${slug}/composer`),
|
||||
// 安装/更新 Composer
|
||||
installComposer: (slug: number): any => http.Post(`/environment/php/${slug}/composer/install`),
|
||||
// 设置 Composer 镜像源
|
||||
setComposerMirror: (slug: number, mirror: string): any =>
|
||||
http.Post(`/environment/php/${slug}/composer/mirror`, { mirror }),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
content: (path: string): any => http.Get('/file/content', { params: { path } }),
|
||||
// 反向分页读取文件/容器/systemd 日志
|
||||
// 文件/容器: 用 offset 从末尾跳过 offset 行,读 limit 行
|
||||
// 文件: 翻页额外传首屏返回的 size 作为锚点,避免期间写入的新日志顶偏移量
|
||||
// systemd 服务: 首次不传 cursor,翻页传上一页返回的 next_cursor,每次读 limit 行
|
||||
tail: (params: {
|
||||
path?: string
|
||||
@@ -15,6 +16,7 @@ export default {
|
||||
offset?: number
|
||||
limit: number
|
||||
cursor?: string
|
||||
size?: number
|
||||
}): any => http.Get('/file/tail', { params }),
|
||||
// 保存文件
|
||||
save: (path: string, content: string): any => http.Post('/file/save', { path, content }),
|
||||
|
||||
@@ -24,13 +24,19 @@ export default {
|
||||
ws.onerror = (e) => reject(e)
|
||||
})
|
||||
},
|
||||
// 文件或 systemd 服务实时跟踪
|
||||
follow: (params: { path?: string; service?: string; container?: string }): Promise<WebSocket> => {
|
||||
// 文件或 systemd 服务实时跟踪,offset 为首屏锚点字节位置
|
||||
follow: (params: {
|
||||
path?: string
|
||||
service?: string
|
||||
container?: string
|
||||
offset?: number
|
||||
}): Promise<WebSocket> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.path) qs.set('path', params.path)
|
||||
if (params.service) qs.set('service', params.service)
|
||||
if (params.container) qs.set('container', params.container)
|
||||
if (params.offset) qs.set('offset', String(params.offset))
|
||||
const ws = new WebSocket(`${base}/follow?${qs.toString()}`)
|
||||
ws.onopen = () => resolve(ws)
|
||||
ws.onerror = (e) => reject(e)
|
||||
|
||||
@@ -28,7 +28,6 @@ interface LogLine {
|
||||
id: number
|
||||
html: string
|
||||
text: string
|
||||
lower: string
|
||||
}
|
||||
|
||||
type ConnStatus = 'connecting' | 'connected' | 'error'
|
||||
@@ -50,6 +49,7 @@ const searchKeyword = ref('')
|
||||
const matchedLineId = ref<number | null>(null)
|
||||
const pendingNew = ref(0)
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const bodyEl = ref<HTMLElement | null>(null)
|
||||
const shellEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(shellEl)
|
||||
@@ -65,9 +65,16 @@ const statusText = computed(() =>
|
||||
// 全屏时弹出层需挂载到全屏元素内部否则不可见
|
||||
const popoverTo = computed(() => (isFullscreen.value ? (shellEl.value ?? 'body') : 'body'))
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let nextId = 0
|
||||
let pendingTail = ''
|
||||
// 帧内攒下的原始行,由 flushIncoming 统一落盘
|
||||
let incoming: string[] = []
|
||||
let flushScheduled = false
|
||||
let loadedFromEnd = 0
|
||||
// 首屏时的文件大小,作为反向翻页与实时跟踪的共同锚点
|
||||
let anchorSize = 0
|
||||
let nextCursor = ''
|
||||
let followWs: WebSocket | null = null
|
||||
let suppressScrollHandler = false
|
||||
@@ -89,15 +96,13 @@ const titleLabel = computed(() => props.path || props.service || props.container
|
||||
const supported = computed(() => !!sourceParams.value)
|
||||
|
||||
// text 为剥离 ANSI 后的纯文本供搜索/复制/关键词标注使用
|
||||
const parseLine = (raw: string): LogLine => {
|
||||
const text = Anser.ansiToText(raw)
|
||||
return {
|
||||
// 行创建后不再变更,markRaw 免掉每行一层 Proxy 与逐字段依赖(5000 行量级下省数 MB)
|
||||
const parseLine = (raw: string): LogLine =>
|
||||
markRaw({
|
||||
id: nextId++,
|
||||
text,
|
||||
lower: text.toLowerCase(),
|
||||
text: Anser.ansiToText(raw),
|
||||
html: Anser.ansiToHtml(Anser.escapeForHtml(raw), { use_classes: true }),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
@@ -109,21 +114,34 @@ const renderLine = (line: LogLine) => {
|
||||
return Anser.escapeForHtml(line.text).replace(re, (m) => `<mark class="log-mark">${m}</mark>`)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
// 程序化滚动的统一入口:期间抑制滚动回调,否则会被误判为用户手动滚动而退出跟随或触发翻页
|
||||
const setScrollTop = (calc: (el: HTMLElement) => number) => {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
suppressScrollHandler = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
el.scrollTop = calc(el)
|
||||
requestAnimationFrame(() => {
|
||||
suppressScrollHandler = false
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = () => setScrollTop((el) => el.scrollHeight)
|
||||
|
||||
// 跳到已加载内容的开头;抑制滚动回调也顺带避免了落到顶部立刻触发翻页又被位置补偿拽回来
|
||||
const scrollToTop = () => {
|
||||
const el = scrollEl.value
|
||||
if (el) el.scrollTop = 0
|
||||
followMode.value = false
|
||||
setScrollTop(() => 0)
|
||||
}
|
||||
|
||||
// 贴底后布局仍会继续变化:弹窗入场动画、横向滚动条出现、字体度量生效、tab 由隐藏转可见,
|
||||
// 单次 scrollToBottom 必然落空,故跟随模式下把"保持贴底"作为不变式由观察器统一维持
|
||||
const stickToBottom = () => {
|
||||
if (followMode.value) scrollToBottom()
|
||||
}
|
||||
// 容器自身高度(全屏切换、窗口缩放)与内容高度(翻页、换行重排)都要观察
|
||||
useResizeObserver(scrollEl, stickToBottom)
|
||||
useResizeObserver(bodyEl, stickToBottom)
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (isManuallyClosed) return
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
@@ -133,11 +151,39 @@ const scheduleReconnect = () => {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const startFollow = () => {
|
||||
// 繁忙日志下 ws 帧率可达上千每秒,逐帧改 lines 就是逐帧整表 patch 加一次强制重排;
|
||||
// 攒到下一帧统一落盘,渲染次数压到 60/秒量级。锚点续读补发积压时这个差距最明显
|
||||
const flushIncoming = () => {
|
||||
flushScheduled = false
|
||||
const total = incoming.length
|
||||
if (total === 0) return
|
||||
// 超出上限的部分永远不会被显示,先裁再解析,省掉白做的 ANSI 转换
|
||||
const batch = total > MAX_LINES ? incoming.slice(-MAX_LINES) : incoming
|
||||
incoming = []
|
||||
lines.value.push(...batch.map(parseLine))
|
||||
// 跟随与暂停都要裁剪;裁掉的历史行要同步退还翻页游标,否则下次上翻会跳过这一段
|
||||
if (lines.value.length > MAX_LINES) {
|
||||
const removed = lines.value.length - MAX_LINES
|
||||
lines.value.splice(0, removed)
|
||||
loadedFromEnd = Math.max(0, loadedFromEnd - removed)
|
||||
}
|
||||
// 稳态下追加与裁剪行数相抵、内容高度不变,观察器不会触发,这里必须自己贴底
|
||||
if (followMode.value) {
|
||||
nextTick(scrollToBottom)
|
||||
} else {
|
||||
pendingNew.value += total
|
||||
}
|
||||
}
|
||||
|
||||
// fromAnchor 仅首次连接时为真:从首屏锚点续读补齐空档,
|
||||
// 重连改用默认的"只跟新增",否则会把锚点之后已显示的内容整段重放
|
||||
const startFollow = (fromAnchor = false) => {
|
||||
if (!sourceParams.value) return
|
||||
isManuallyClosed = false
|
||||
status.value = 'connecting'
|
||||
ws.follow(sourceParams.value)
|
||||
// 上次连接残留的半行与新流拼接会拼出错行
|
||||
pendingTail = ''
|
||||
ws.follow({ ...sourceParams.value, offset: fromAnchor ? anchorSize : 0 })
|
||||
.then((socket) => {
|
||||
followWs = socket
|
||||
socket.binaryType = 'arraybuffer'
|
||||
@@ -145,21 +191,14 @@ const startFollow = () => {
|
||||
|
||||
socket.onmessage = (ev) => {
|
||||
const data: string =
|
||||
typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(new Uint8Array(ev.data))
|
||||
const combined = pendingTail + data
|
||||
const parts = combined.split('\n')
|
||||
typeof ev.data === 'string' ? ev.data : decoder.decode(new Uint8Array(ev.data))
|
||||
const parts = (pendingTail + data).split('\n')
|
||||
pendingTail = parts.pop() ?? ''
|
||||
if (parts.length > 0) {
|
||||
lines.value.push(...parts.map(parseLine))
|
||||
// 跟随与暂停都要裁剪
|
||||
if (lines.value.length > MAX_LINES) {
|
||||
lines.value.splice(0, lines.value.length - MAX_LINES)
|
||||
}
|
||||
if (followMode.value) {
|
||||
nextTick(() => scrollToBottom())
|
||||
} else {
|
||||
pendingNew.value += parts.length
|
||||
}
|
||||
if (parts.length === 0) return
|
||||
incoming.push(...parts)
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true
|
||||
requestAnimationFrame(flushIncoming)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +220,10 @@ const startFollow = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// hasMore 只表达服务端还有没有更早的日志;要不要继续拿是客户端策略,
|
||||
// 分开后实时裁剪把行数降回上限以下时,向上翻页能自动恢复
|
||||
const canLoadOlder = computed(() => hasMore.value && lines.value.length < MAX_LINES)
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
const buildTailParams = (initial: boolean) => {
|
||||
@@ -189,6 +232,8 @@ const buildTailParams = (initial: boolean) => {
|
||||
if (!initial) base.cursor = nextCursor
|
||||
} else {
|
||||
base.offset = initial ? 0 : loadedFromEnd
|
||||
// 带上锚点,翻页始终相对首屏那一刻的文件末尾,不受期间写入的新日志影响
|
||||
if (!initial && anchorSize > 0) base.size = anchorSize
|
||||
}
|
||||
return base as any
|
||||
}
|
||||
@@ -201,12 +246,15 @@ const loadInitial = () => {
|
||||
const newLines: string[] = data?.lines ?? []
|
||||
lines.value = newLines.map(parseLine)
|
||||
loadedFromEnd = newLines.length
|
||||
anchorSize = data?.size ?? 0
|
||||
nextCursor = data?.next_cursor ?? ''
|
||||
hasMore.value = data?.has_more ?? false
|
||||
// 与日志行同一次渲染中撤下加载占位,否则 nextTick 时 DOM 里还没有行,贴底会落空
|
||||
initialLoading.value = false
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
followMode.value = true
|
||||
startFollow()
|
||||
startFollow(true)
|
||||
})
|
||||
})
|
||||
.onComplete(() => {
|
||||
@@ -215,7 +263,7 @@ const loadInitial = () => {
|
||||
}
|
||||
|
||||
const loadOlder = () => {
|
||||
if (!sourceParams.value || isLoadingMore.value || !hasMore.value) return
|
||||
if (!sourceParams.value || isLoadingMore.value || !canLoadOlder.value) return
|
||||
if (props.service && !nextCursor) {
|
||||
hasMore.value = false
|
||||
return
|
||||
@@ -238,16 +286,7 @@ const loadOlder = () => {
|
||||
nextCursor = data?.next_cursor ?? ''
|
||||
hasMore.value = data?.has_more ?? false
|
||||
// 保持视觉位置:scrollTop = 新 scrollHeight - 旧 scrollHeight + 旧 scrollTop
|
||||
nextTick(() => {
|
||||
const target = scrollEl.value
|
||||
if (target) {
|
||||
suppressScrollHandler = true
|
||||
target.scrollTop = target.scrollHeight - oldScrollHeight + oldScrollTop
|
||||
requestAnimationFrame(() => {
|
||||
suppressScrollHandler = false
|
||||
})
|
||||
}
|
||||
})
|
||||
nextTick(() => setScrollTop((el) => el.scrollHeight - oldScrollHeight + oldScrollTop))
|
||||
})
|
||||
.onComplete(() => {
|
||||
isLoadingMore.value = false
|
||||
@@ -260,7 +299,7 @@ const onScroll = () => {
|
||||
if (!el) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = el
|
||||
followMode.value = scrollHeight - scrollTop - clientHeight < 30
|
||||
if (scrollTop < 60 && hasMore.value && !isLoadingMore.value) {
|
||||
if (scrollTop < 60 && canLoadOlder.value && !isLoadingMore.value) {
|
||||
loadOlder()
|
||||
}
|
||||
}
|
||||
@@ -278,9 +317,9 @@ const toggleFollow = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 换行重排后的贴底由观察器负责
|
||||
const toggleWrap = () => {
|
||||
wrapLines.value = !wrapLines.value
|
||||
if (followMode.value) nextTick(() => scrollToBottom())
|
||||
}
|
||||
|
||||
const copyAll = () => {
|
||||
@@ -298,10 +337,12 @@ const decreaseFont = () => {
|
||||
if (fontSize.value > 10) fontSize.value--
|
||||
}
|
||||
|
||||
// 用不区分大小写的正则匹配,免去为每行常驻一份小写副本
|
||||
const matches = computed(() => {
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
const kw = searchKeyword.value
|
||||
if (!kw) return []
|
||||
return lines.value.filter((l) => l.lower.includes(kw))
|
||||
const re = new RegExp(escapeRegExp(kw), 'i')
|
||||
return lines.value.filter((l) => re.test(l.text))
|
||||
})
|
||||
|
||||
const matchPos = computed(() => matches.value.findIndex((m) => m.id === matchedLineId.value))
|
||||
@@ -320,11 +361,18 @@ const goToMatch = (step: 1 | -1) => {
|
||||
const target = ms[next]
|
||||
if (!target) return
|
||||
matchedLineId.value = target.id
|
||||
const el = scrollEl.value.querySelector(`.log-line[data-id="${target.id}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'center' })
|
||||
followMode.value = false
|
||||
}
|
||||
const el = scrollEl.value.querySelector<HTMLElement>(`.log-line[data-id="${target.id}"]`)
|
||||
if (!el) return
|
||||
// 不用 scrollIntoView,它会连带滚动外层页面(组件多数内嵌在 tab 里而非弹窗);
|
||||
// 按两者的相对位置算,不依赖 offsetParent 落在哪一层
|
||||
followMode.value = false
|
||||
setScrollTop(
|
||||
(c) =>
|
||||
c.scrollTop +
|
||||
el.getBoundingClientRect().top -
|
||||
c.getBoundingClientRect().top -
|
||||
(c.clientHeight - el.offsetHeight) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
// 关键字变化时重置搜索游标与高亮
|
||||
@@ -337,11 +385,6 @@ watch(followMode, (on) => {
|
||||
if (on) pendingNew.value = 0
|
||||
})
|
||||
|
||||
// 全屏切换后容器高度变化重新贴底
|
||||
watch(isFullscreen, () => {
|
||||
if (followMode.value) nextTick(() => scrollToBottom())
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
isManuallyClosed = true
|
||||
if (reconnectTimer) {
|
||||
@@ -352,8 +395,10 @@ const cleanup = () => {
|
||||
followWs = null
|
||||
lines.value = []
|
||||
loadedFromEnd = 0
|
||||
anchorSize = 0
|
||||
nextCursor = ''
|
||||
pendingTail = ''
|
||||
incoming = []
|
||||
hasMore.value = false
|
||||
loadedOlder.value = false
|
||||
status.value = 'connecting'
|
||||
@@ -474,7 +519,7 @@ defineExpose({ clear })
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div v-if="initialLoading" class="log-loading"><n-spin :size="18" /></div>
|
||||
<template v-else>
|
||||
<div v-else ref="bodyEl">
|
||||
<div v-if="isLoadingMore || (loadedOlder && !hasMore)" class="log-boundary">
|
||||
<n-spin v-if="isLoadingMore" :size="12" />
|
||||
<span v-else>{{ $gettext('No more logs') }}</span>
|
||||
@@ -487,7 +532,10 @@ defineExpose({ clear })
|
||||
:data-id="line.id"
|
||||
v-html="renderLine(line)"
|
||||
></div>
|
||||
</template>
|
||||
<div v-if="lines.length === 0" class="log-boundary">
|
||||
{{ $gettext('No logs available') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="pill">
|
||||
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
service: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
container: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -64,6 +68,12 @@ defineExpose({ clear })
|
||||
</ConfirmDialog>
|
||||
</n-flex>
|
||||
</template>
|
||||
<realtime-log v-if="show" ref="logRef" :path="props.path" :container="props.container" />
|
||||
<realtime-log
|
||||
v-if="show"
|
||||
ref="logRef"
|
||||
:path="props.path"
|
||||
:service="props.service"
|
||||
:container="props.container"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
+1148
-302
File diff suppressed because it is too large
Load Diff
+1043
-299
File diff suppressed because it is too large
Load Diff
+1036
-292
File diff suppressed because it is too large
Load Diff
+1036
-292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'apache-config-tune',
|
||||
})
|
||||
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import apache from '@/api/apps/apache'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const currentTab = ref('mpm')
|
||||
|
||||
// MPM 事件模型
|
||||
const startServers = ref<number | null>(null)
|
||||
const minSpareThreads = ref<number | null>(null)
|
||||
const maxSpareThreads = ref<number | null>(null)
|
||||
const threadsPerChild = ref<number | null>(null)
|
||||
const maxRequestWorkers = ref<number | null>(null)
|
||||
const maxConnectionsPerChild = ref<number | null>(null)
|
||||
|
||||
// 连接设置
|
||||
const timeout = ref<number | null>(null)
|
||||
const keepAlive = ref('')
|
||||
const maxKeepAliveRequests = ref<number | null>(null)
|
||||
const keepAliveTimeout = ref<number | null>(null)
|
||||
|
||||
const saveLoading = ref(false)
|
||||
|
||||
const onOffOptions = [
|
||||
{ label: 'On', value: 'On' },
|
||||
{ label: 'Off', value: 'Off' },
|
||||
]
|
||||
|
||||
useRequest(apache.configTune()).onSuccess(({ data }: any) => {
|
||||
startServers.value = Number(data.start_servers) || null
|
||||
minSpareThreads.value = Number(data.min_spare_threads) || null
|
||||
maxSpareThreads.value = Number(data.max_spare_threads) || null
|
||||
threadsPerChild.value = Number(data.threads_per_child) || null
|
||||
maxRequestWorkers.value = Number(data.max_request_workers) || null
|
||||
maxConnectionsPerChild.value = data.max_connections_per_child
|
||||
? Number(data.max_connections_per_child)
|
||||
: null
|
||||
timeout.value = Number(data.timeout) || null
|
||||
keepAlive.value = data.keep_alive || null
|
||||
maxKeepAliveRequests.value = data.max_keep_alive_requests
|
||||
? Number(data.max_keep_alive_requests)
|
||||
: null
|
||||
keepAliveTimeout.value = Number(data.keep_alive_timeout) || null
|
||||
})
|
||||
|
||||
const getConfigData = () => ({
|
||||
start_servers: String(startServers.value ?? ''),
|
||||
min_spare_threads: String(minSpareThreads.value ?? ''),
|
||||
max_spare_threads: String(maxSpareThreads.value ?? ''),
|
||||
threads_per_child: String(threadsPerChild.value ?? ''),
|
||||
max_request_workers: String(maxRequestWorkers.value ?? ''),
|
||||
max_connections_per_child: String(maxConnectionsPerChild.value ?? ''),
|
||||
timeout: String(timeout.value ?? ''),
|
||||
keep_alive: keepAlive.value ?? '',
|
||||
max_keep_alive_requests: String(maxKeepAliveRequests.value ?? ''),
|
||||
keep_alive_timeout: String(keepAliveTimeout.value ?? ''),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
saveLoading.value = true
|
||||
useRequest(apache.saveConfigTune(getConfigData()))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
saveLoading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs v-model:value="currentTab" type="line" placement="left" animated>
|
||||
<n-tab-pane name="mpm" :tab="$gettext('MPM Event')">
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{ $gettext('Worker thread pool settings for the event MPM.') }}
|
||||
</n-alert>
|
||||
<n-form>
|
||||
<n-form-item :label="$gettext('Start Servers (StartServers)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="startServers"
|
||||
:placeholder="$gettext('e.g. 3')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Min Spare Threads (MinSpareThreads)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="minSpareThreads"
|
||||
:placeholder="$gettext('e.g. 75')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Max Spare Threads (MaxSpareThreads)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="maxSpareThreads"
|
||||
:placeholder="$gettext('e.g. 250')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Threads Per Child (ThreadsPerChild)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="threadsPerChild"
|
||||
:placeholder="$gettext('e.g. 25')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Max Request Workers (MaxRequestWorkers)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="maxRequestWorkers"
|
||||
:placeholder="$gettext('e.g. 400')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Max Connections Per Child (MaxConnectionsPerChild)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="maxConnectionsPerChild"
|
||||
:placeholder="$gettext('0 means unlimited')"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="saveLoading"
|
||||
:disabled="saveLoading"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="connection" :tab="$gettext('Connection')">
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{ $gettext('Connection and keep-alive settings.') }}
|
||||
</n-alert>
|
||||
<n-form>
|
||||
<n-form-item :label="$gettext('Timeout (Timeout)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="timeout"
|
||||
:placeholder="$gettext('e.g. 60')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Keep Alive (KeepAlive)')">
|
||||
<n-select v-model:value="keepAlive" :options="onOffOptions" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Max Keep Alive Requests (MaxKeepAliveRequests)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="maxKeepAliveRequests"
|
||||
:placeholder="$gettext('0 means unlimited')"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Keep Alive Timeout (KeepAliveTimeout)')">
|
||||
<n-input-number
|
||||
class="w-full"
|
||||
v-model:value="keepAliveTimeout"
|
||||
:placeholder="$gettext('e.g. 5')"
|
||||
:min="1"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="saveLoading"
|
||||
:disabled="saveLoading"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -8,6 +8,8 @@ import { useGettext } from 'vue3-gettext'
|
||||
import apache from '@/api/apps/apache'
|
||||
import ServiceStatus from '@/components/common/ServiceStatus.vue'
|
||||
|
||||
import ApacheConfigTuneView from './ApacheConfigTuneView.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const currentTab = ref('status')
|
||||
const saveConfigLoading = ref(false)
|
||||
@@ -94,6 +96,9 @@ const handleClearErrorLog = () => {
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
|
||||
<apache-config-tune-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -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,271 @@
|
||||
<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: 145 },
|
||||
{ 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: 145 },
|
||||
{ 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: 150 },
|
||||
{ title: $gettext('Waiting SQL'), key: 'waiting_query', minWidth: 200, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Blocking Thread'), key: 'blocking_thread_id', width: 150 },
|
||||
{
|
||||
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="1365"
|
||||
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="1020"
|
||||
/>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="transactionColumns"
|
||||
:data="transactions.transactions"
|
||||
:scroll-x="1315"
|
||||
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>
|
||||
<template v-else-if="!topSQL.enabled">
|
||||
<n-alert 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-alert>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="enableTopSQLLoading"
|
||||
:disabled="enableTopSQLLoading"
|
||||
@click="handleEnableTopSQL"
|
||||
>
|
||||
{{ $gettext('Enable') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<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="1365"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -13,8 +13,11 @@ import systemctl from '@/api/panel/systemctl'
|
||||
import ServiceStatus from '@/components/common/ServiceStatus.vue'
|
||||
|
||||
import PostgresqlConfigTuneView from './PostgresqlConfigTuneView.vue'
|
||||
import PostgresqlExtensionsView from './PostgresqlExtensionsView.vue'
|
||||
import PostgresqlMaintenanceView from './PostgresqlMaintenanceView.vue'
|
||||
import PostgresqlPerformanceView from './PostgresqlPerformanceView.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { $gettext, $pgettext } = useGettext()
|
||||
const currentTab = ref('status')
|
||||
const setPostgresPasswordLoading = ref(false)
|
||||
const saveConfigLoading = ref(false)
|
||||
@@ -210,6 +213,15 @@ const handleCopyPostgresPassword = () => {
|
||||
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
|
||||
<postgresql-config-tune-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="extensions" :tab="$pgettext('postgresql', 'Extensions')">
|
||||
<postgresql-extensions-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="performance" :tab="$gettext('Performance')">
|
||||
<postgresql-performance-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="$gettext('Maintenance')">
|
||||
<postgresql-maintenance-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user-config" :tab="$gettext('User Configuration')">
|
||||
<n-flex vertical>
|
||||
<n-alert type="warning">
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'postgresql-extensions',
|
||||
})
|
||||
|
||||
import { NButton, NDataTable, NSpace, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import postgresql from '@/api/apps/postgresql'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const showEnableModal = ref(false)
|
||||
const enableSlug = ref('')
|
||||
const enableExtName = ref('')
|
||||
const enableDatabase = ref('')
|
||||
const enableLoading = ref(false)
|
||||
|
||||
const { data: extensions, send: refreshExtensions } = useRequest(postgresql.extensions, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: databases, send: fetchDatabases } = useRequest(postgresql.databases, {
|
||||
immediate: false,
|
||||
initialData: [],
|
||||
})
|
||||
|
||||
const databaseOptions = computed(() =>
|
||||
(databases.value as string[]).map((name) => ({ label: name, value: name })),
|
||||
)
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
title: $gettext('Name'),
|
||||
key: 'name',
|
||||
minWidth: 150,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Extension Name'),
|
||||
key: 'ext_name',
|
||||
minWidth: 150,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Description'),
|
||||
key: 'description',
|
||||
minWidth: 250,
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: $gettext('Status'),
|
||||
key: 'status',
|
||||
width: 150,
|
||||
render(row: any) {
|
||||
if (!row.installed) {
|
||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => $gettext('Not Installed') })
|
||||
}
|
||||
const label = row.installed_version
|
||||
? `${$gettext('Installed')} (${row.installed_version})`
|
||||
: $gettext('Installed')
|
||||
return h(NTag, { type: 'success', size: 'small' }, { default: () => label })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 300,
|
||||
render(row: any) {
|
||||
if (!row.installed) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'info',
|
||||
onClick: async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'info',
|
||||
title: $gettext('Confirm Install'),
|
||||
content: $gettext('Are you sure you want to install %{ name }?', {
|
||||
name: row.name,
|
||||
}),
|
||||
})
|
||||
if (ok) handleInstall(row.slug)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Install') },
|
||||
)
|
||||
}
|
||||
return h(NSpace, { size: 'small', wrap: false }, {
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => handleOpenEnable(row),
|
||||
},
|
||||
{ default: () => $gettext('Enable') },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
onClick: async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'info',
|
||||
title: $gettext('Confirm Reinstall'),
|
||||
content: $gettext(
|
||||
'Reinstalling will recompile %{ name } to the latest version provided by the panel. Are you sure?',
|
||||
{ name: row.name },
|
||||
),
|
||||
})
|
||||
if (ok) handleInstall(row.slug)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Reinstall') },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Uninstall'),
|
||||
content: $gettext(
|
||||
'Please make sure the extension %{ ext_name } has been dropped (DROP EXTENSION) in all databases that use it, otherwise those databases will fail to load it. Are you sure you want to uninstall %{ name }?',
|
||||
{ name: row.name, ext_name: row.ext_name },
|
||||
),
|
||||
positiveText: $gettext('Uninstall'),
|
||||
countdown: 5,
|
||||
})
|
||||
if (ok) handleUninstall(row.slug)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Uninstall') },
|
||||
),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const handleOpenEnable = (row: any) => {
|
||||
enableSlug.value = row.slug
|
||||
enableExtName.value = row.ext_name
|
||||
enableDatabase.value = ''
|
||||
fetchDatabases()
|
||||
showEnableModal.value = true
|
||||
}
|
||||
|
||||
const handleEnable = () => {
|
||||
if (!enableDatabase.value) return
|
||||
enableLoading.value = true
|
||||
useRequest(postgresql.enableExtension(enableSlug.value, enableDatabase.value))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Enabled successfully'))
|
||||
showEnableModal.value = false
|
||||
})
|
||||
.onComplete(() => {
|
||||
enableLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleInstall = (slug: string) => {
|
||||
useRequest(postgresql.installExtension(slug)).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
refreshExtensions()
|
||||
})
|
||||
}
|
||||
|
||||
const handleUninstall = (slug: string) => {
|
||||
useRequest(postgresql.uninstallExtension(slug)).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
refreshExtensions()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'Extensions need to be enabled in the database with CREATE EXTENSION, and some may require restarting PostgreSQL.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-data-table striped :columns="columns" :data="extensions" :scroll-x="1000" />
|
||||
<n-modal
|
||||
v-model:show="showEnableModal"
|
||||
preset="card"
|
||||
:title="$gettext('Enable Extension') + ' - ' + enableSlug"
|
||||
class="w-120"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'CREATE EXTENSION %{ ext_name } will be executed in the selected database. Enabling in template1 makes new databases inherit it.',
|
||||
{ ext_name: enableExtName },
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-select
|
||||
v-model:value="enableDatabase"
|
||||
:options="databaseOptions"
|
||||
:placeholder="$gettext('Select database')"
|
||||
/>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="enableLoading"
|
||||
:disabled="enableLoading || !enableDatabase"
|
||||
@click="handleEnable"
|
||||
>
|
||||
{{ $gettext('Enable') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'postgresql-maintenance',
|
||||
})
|
||||
|
||||
import { NButton, NSpace, NTag, NTooltip } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import postgresql from '@/api/apps/postgresql'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const selectedDatabase = ref('')
|
||||
|
||||
const { data: databases } = useRequest(postgresql.databases, {
|
||||
initialData: [],
|
||||
}).onSuccess(({ data }) => {
|
||||
if (data?.length && !selectedDatabase.value) {
|
||||
// 默认选中 postgres 库
|
||||
selectedDatabase.value = data.includes('postgres') ? 'postgres' : data[0]
|
||||
refreshBloat()
|
||||
}
|
||||
})
|
||||
|
||||
const databaseOptions = computed(() =>
|
||||
(databases.value as string[]).map((name) => ({ label: name, value: name })),
|
||||
)
|
||||
|
||||
const { data: bloat, send: sendBloat } = useRequest(
|
||||
() => postgresql.bloat(selectedDatabase.value),
|
||||
{
|
||||
immediate: false,
|
||||
initialData: { repack_installed: false, items: [] },
|
||||
},
|
||||
)
|
||||
const refreshBloat = () => {
|
||||
if (selectedDatabase.value) sendBloat()
|
||||
}
|
||||
|
||||
const { data: wal, send: refreshWal } = useRequest(postgresql.wal, {
|
||||
initialData: {
|
||||
wal_size: '-',
|
||||
archiver: { archived_count: 0, failed_count: 0, last_archived_wal: '', last_failed_wal: '' },
|
||||
slots: [],
|
||||
replications: [],
|
||||
},
|
||||
})
|
||||
|
||||
const handleMaintenance = async (row: any, operation: string) => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Operation'),
|
||||
content: $gettext('Are you sure you want to run %{ op } on %{ table }?', {
|
||||
op: operation.toUpperCase(),
|
||||
table: `${row.schema}.${row.table}`,
|
||||
}),
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(
|
||||
postgresql.runMaintenance({
|
||||
database: selectedDatabase.value,
|
||||
schema: row.schema,
|
||||
table: row.table,
|
||||
operation,
|
||||
}),
|
||||
).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
})
|
||||
}
|
||||
|
||||
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: 120 },
|
||||
{ title: $gettext('Dead Tuples'), key: 'dead_tuples', width: 130 },
|
||||
{
|
||||
title: $gettext('Dead Rate'),
|
||||
key: 'dead_rate',
|
||||
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}%` })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Last Vacuum'),
|
||||
key: 'last_vacuum',
|
||||
width: 150,
|
||||
render: (row: any) => row.last_vacuum || row.last_autovacuum || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Last Analyze'),
|
||||
key: 'last_analyze',
|
||||
width: 150,
|
||||
render: (row: any) => row.last_analyze || row.last_autoanalyze || '-',
|
||||
},
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 310,
|
||||
render(row: any) {
|
||||
const buttons = [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', type: 'info', onClick: () => handleMaintenance(row, 'vacuum') },
|
||||
{ default: () => 'VACUUM' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', onClick: () => handleMaintenance(row, 'analyze') },
|
||||
{ default: () => 'ANALYZE' },
|
||||
),
|
||||
]
|
||||
if (bloat.value.repack_installed) {
|
||||
buttons.push(
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', type: 'warning', onClick: () => handleMaintenance(row, 'repack') },
|
||||
{ default: () => 'REPACK' },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
buttons.push(
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'small', disabled: true }, { default: () => 'REPACK' }),
|
||||
default: () =>
|
||||
$gettext('pg_repack is not installed, please install it in the extensions tab'),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
return h(NSpace, { size: 'small', wrap: false }, { default: () => buttons })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const slotColumns: any = [
|
||||
{ title: $gettext('Slot Name'), key: 'name', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Type'), key: 'type', width: 110 },
|
||||
{
|
||||
title: $gettext('Active'),
|
||||
key: 'active',
|
||||
width: 100,
|
||||
render(row: any) {
|
||||
return row.active
|
||||
? h(NTag, { type: 'success', size: 'small' }, { default: () => $gettext('Yes') })
|
||||
: h(NTag, { type: 'warning', size: 'small' }, { default: () => $gettext('No') })
|
||||
},
|
||||
},
|
||||
{ title: $gettext('Retained WAL'), key: 'retained_wal', width: 150 },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render(row: any) {
|
||||
if (row.active) return '-'
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Delete'),
|
||||
content: $gettext(
|
||||
'After deleting slot %{ name }, the corresponding subscription or standby will not be able to continue syncing. Are you sure?',
|
||||
{ name: row.name },
|
||||
),
|
||||
positiveText: $gettext('Delete'),
|
||||
countdown: 5,
|
||||
})
|
||||
if (ok) handleDropSlot(row.name)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Delete') },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const replicationColumns: any = [
|
||||
{ title: $gettext('Client'), key: 'client_addr', minWidth: 150 },
|
||||
{ title: $gettext('State'), key: 'state', width: 120 },
|
||||
{ title: $gettext('Sync State'), key: 'sync_state', width: 120 },
|
||||
{ title: $gettext('Lag'), key: 'lag', width: 120 },
|
||||
]
|
||||
|
||||
const handleDropSlot = (name: string) => {
|
||||
useRequest(postgresql.dropReplicationSlot(name)).onSuccess(() => {
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
refreshWal()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="bloat" :tab="$gettext('Table Bloat')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-select
|
||||
v-model:value="selectedDatabase"
|
||||
:options="databaseOptions"
|
||||
class="w-60"
|
||||
@update:value="refreshBloat"
|
||||
/>
|
||||
<n-button type="primary" @click="refreshBloat">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="bloatColumns"
|
||||
:data="bloat.items"
|
||||
:scroll-x="1370"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="wal" tab="WAL">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshWal()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-card :title="$gettext('WAL Status')">
|
||||
<n-flex>
|
||||
<n-statistic :label="$gettext('WAL Size')" :value="wal.wal_size" />
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Archived Count')"
|
||||
:value="wal.archiver.archived_count"
|
||||
/>
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Archive Failed Count')"
|
||||
:value="wal.archiver.failed_count"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card :title="$gettext('Replication Slots')">
|
||||
<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
|
||||
striped
|
||||
:columns="replicationColumns"
|
||||
:data="wal.replications"
|
||||
:scroll-x="510"
|
||||
/>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'postgresql-performance',
|
||||
})
|
||||
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import postgresql from '@/api/apps/postgresql'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const enableTopSQLLoading = ref(false)
|
||||
|
||||
const { data: sessions, send: refreshSessions } = useRequest(postgresql.sessions, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: topSQL, send: refreshTopSQL } = useRequest(postgresql.topSQL, {
|
||||
initialData: { enabled: true, pending_restart: false, items: [] },
|
||||
})
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (!seconds || seconds <= 0) return '-'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
||||
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
const sessionColumns: any = [
|
||||
{ title: 'PID', key: 'pid', width: 90 },
|
||||
{ title: $gettext('Database'), key: 'database', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('User'), key: 'user', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Client'), key: 'client_addr', width: 140, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('State'), key: 'state', width: 100, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Wait Event'),
|
||||
key: 'wait_event',
|
||||
width: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row: any) {
|
||||
if (!row.wait_event) return '-'
|
||||
return `${row.wait_event_type}: ${row.wait_event}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Blocked By'),
|
||||
key: 'blocked_by',
|
||||
width: 110,
|
||||
render(row: any) {
|
||||
if (!row.blocked_by) return '-'
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.blocked_by })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $gettext('Transaction Duration'),
|
||||
key: 'xact_seconds',
|
||||
width: 190,
|
||||
render: (row: any) => formatDuration(row.xact_seconds),
|
||||
},
|
||||
{
|
||||
title: $gettext('Query Duration'),
|
||||
key: 'query_seconds',
|
||||
width: 150,
|
||||
render: (row: any) => formatDuration(row.query_seconds),
|
||||
},
|
||||
{ title: 'SQL', key: 'query', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render(row: any) {
|
||||
return 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 session %{ pid }?', {
|
||||
pid: String(row.pid),
|
||||
}),
|
||||
positiveText: $gettext('Terminate'),
|
||||
})
|
||||
if (ok) handleTerminate(row.pid)
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Terminate') },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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'), key: 'rows', width: 100 },
|
||||
{
|
||||
title: $gettext('Cache Hit Rate'),
|
||||
key: 'hit_rate',
|
||||
width: 140,
|
||||
render: (row: any) => `${row.hit_rate}%`,
|
||||
},
|
||||
{ title: 'SQL', key: 'query', minWidth: 300, ellipsis: { tooltip: true } },
|
||||
]
|
||||
|
||||
const handleTerminate = (pid: number) => {
|
||||
useRequest(postgresql.terminateSession(pid)).onSuccess(() => {
|
||||
window.$message.success($gettext('Terminated successfully'))
|
||||
refreshSessions()
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnableTopSQL = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'info',
|
||||
title: $gettext('Confirm Enable'),
|
||||
content: $gettext(
|
||||
'This will add pg_stat_statements to shared_preload_libraries, a restart of PostgreSQL is required to take effect.',
|
||||
),
|
||||
})
|
||||
if (!ok) return
|
||||
enableTopSQLLoading.value = true
|
||||
useRequest(postgresql.enableTopSQL())
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Enabled, please restart PostgreSQL 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(postgresql.resetTopSQL()).onSuccess(() => {
|
||||
window.$message.success($gettext('Reset successfully'))
|
||||
refreshTopSQL()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="sessions" :tab="$gettext('Sessions')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshSessions()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="sessionColumns"
|
||||
:data="sessions"
|
||||
:scroll-x="1720"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="top-sql" :tab="'Top SQL'">
|
||||
<n-flex vertical>
|
||||
<n-alert v-if="!topSQL.enabled && topSQL.pending_restart" type="warning">
|
||||
{{
|
||||
$gettext('pg_stat_statements is configured, restart PostgreSQL to take effect.')
|
||||
}}
|
||||
</n-alert>
|
||||
<template v-else-if="!topSQL.enabled">
|
||||
<n-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'pg_stat_statements is not enabled. After enabling, SQL performance statistics will be collected, a restart of PostgreSQL is required.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="enableTopSQLLoading"
|
||||
:disabled="enableTopSQLLoading"
|
||||
@click="handleEnableTopSQL"
|
||||
>
|
||||
{{ $gettext('Enable') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<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="1300"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -10,6 +10,8 @@ import redis from '@/api/apps/redis'
|
||||
import ServiceStatus from '@/components/common/ServiceStatus.vue'
|
||||
|
||||
import RedisConfigTuneView from './RedisConfigTuneView.vue'
|
||||
import RedisPerformanceView from '@/views/apps/redis/RedisPerformanceView.vue'
|
||||
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const currentTab = ref('status')
|
||||
@@ -87,6 +89,9 @@ const handleSaveConfig = () => {
|
||||
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
|
||||
<redis-config-tune-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="performance" :tab="$gettext('Performance')">
|
||||
<redis-performance-view :api="redis" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import type redis from '@/api/apps/redis'
|
||||
import { useConfirm } from '@/components/system/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
api: typeof redis
|
||||
}>()
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { confirmDelete, confirmAction } = useConfirm()
|
||||
|
||||
const { data: slowLog, send: refreshSlowLog } = useRequest(props.api.slowLog, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: clients, send: refreshClients } = useRequest(props.api.clients, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: memory, send: refreshMemory } = useRequest(props.api.memory, {
|
||||
initialData: { doctor: '', items: [] },
|
||||
})
|
||||
|
||||
const slowLogColumns: any = [
|
||||
{ title: 'ID', key: 'id', width: 90 },
|
||||
{ title: $gettext('Time'), key: 'time', width: 180 },
|
||||
{
|
||||
title: $gettext('Duration (ms)'),
|
||||
key: 'duration_us',
|
||||
width: 150,
|
||||
render: (row: any) => Math.round(row.duration_us / 10) / 100,
|
||||
},
|
||||
{ title: $gettext('Command'), key: 'command', minWidth: 300, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Client'), key: 'client', width: 160, ellipsis: { tooltip: true } },
|
||||
]
|
||||
|
||||
const clientColumns: any = [
|
||||
{ title: 'ID', key: 'id', width: 90 },
|
||||
{ title: $gettext('Address'), key: 'addr', width: 170, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Name'), key: 'name', width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Database'), key: 'db', width: 100 },
|
||||
{ title: $gettext('Age (s)'), key: 'age', width: 130 },
|
||||
{ title: $gettext('Idle (s)'), key: 'idle', width: 130 },
|
||||
{ title: $gettext('Command'), key: 'cmd', minWidth: 150, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
onClick: async () => {
|
||||
const ok = await confirmDelete({
|
||||
title: $gettext('Confirm Kill'),
|
||||
content: $gettext('Are you sure you want to kill connection %{ addr }?', {
|
||||
addr: row.addr,
|
||||
}),
|
||||
positiveText: $gettext('Kill'),
|
||||
})
|
||||
if (ok) handleKillClient(Number(row.id))
|
||||
},
|
||||
},
|
||||
{ default: () => $gettext('Kill') },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const memoryColumns: any = [
|
||||
{ title: $gettext('Property'), key: 'name', minWidth: 200, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Current Value'), key: 'value', minWidth: 200, ellipsis: { tooltip: true } },
|
||||
]
|
||||
|
||||
const handleResetSlowLog = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Reset'),
|
||||
content: $gettext('Are you sure you want to reset the slow log?'),
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(props.api.resetSlowLog()).onSuccess(() => {
|
||||
window.$message.success($gettext('Reset successfully'))
|
||||
refreshSlowLog()
|
||||
})
|
||||
}
|
||||
|
||||
const handleKillClient = (id: number) => {
|
||||
useRequest(props.api.killClient(id)).onSuccess(() => {
|
||||
window.$message.success($gettext('Killed successfully'))
|
||||
refreshClients()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScanBigKeys = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'info',
|
||||
title: $gettext('Confirm Scan'),
|
||||
content: $gettext(
|
||||
'Scanning traverses all keys and may take a while on large datasets. The result will be in the task log.',
|
||||
),
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(props.api.scanBigKeys()).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="slow-log" :tab="$gettext('Slow Log')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshSlowLog()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
<n-button type="warning" @click="handleResetSlowLog">
|
||||
{{ $gettext('Reset Slow Log') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="slowLogColumns"
|
||||
:data="slowLog"
|
||||
:scroll-x="880"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="clients" :tab="$gettext('Clients')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshClients()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="clientColumns"
|
||||
:data="clients"
|
||||
:scroll-x="1010"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="memory" :tab="$gettext('Memory')">
|
||||
<n-flex vertical>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshMemory()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
<n-button type="info" @click="handleScanBigKeys">
|
||||
{{ $gettext('Scan Big Keys') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-alert v-if="memory.doctor" type="info">
|
||||
{{ memory.doctor }}
|
||||
</n-alert>
|
||||
<n-data-table striped :columns="memoryColumns" :data="memory.items" :scroll-x="400" />
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -10,6 +10,8 @@ import valkey from '@/api/apps/valkey'
|
||||
import ServiceStatus from '@/components/common/ServiceStatus.vue'
|
||||
|
||||
import ValkeyConfigTuneView from './ValkeyConfigTuneView.vue'
|
||||
import RedisPerformanceView from '@/views/apps/redis/RedisPerformanceView.vue'
|
||||
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const currentTab = ref('status')
|
||||
@@ -87,6 +89,9 @@ const handleSaveConfig = () => {
|
||||
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
|
||||
<valkey-config-tune-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="performance" :tab="$gettext('Performance')">
|
||||
<redis-performance-view :api="valkey" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -51,6 +51,61 @@ const { data: load } = useRequest(php.load(slug), {
|
||||
const { data: modules } = useRequest(php.modules(slug), {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: processes, send: refreshProcesses } = useRequest(php.processes(slug), {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: opcache, send: refreshOpcache } = useRequest(php.opcache(slug), {
|
||||
initialData: { enabled: true },
|
||||
})
|
||||
const { data: composer, send: refreshComposer } = useRequest(php.composer(slug), {
|
||||
initialData: { installed: true, version: '', mirror: '' },
|
||||
})
|
||||
|
||||
const composerMirror = ref('')
|
||||
watch(
|
||||
() => composer.value?.mirror,
|
||||
(val) => {
|
||||
composerMirror.value = val ?? ''
|
||||
},
|
||||
)
|
||||
|
||||
const composerMirrorOptions = computed(() => [
|
||||
{ label: $gettext('Official'), value: '' },
|
||||
{ label: $gettext('Aliyun Mirror'), value: 'https://mirrors.aliyun.com/composer/' },
|
||||
{ label: $gettext('Tencent Mirror'), value: 'https://mirrors.tencent.com/composer/' },
|
||||
])
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024
|
||||
i++
|
||||
}
|
||||
return `${Math.round(bytes * 10) / 10} ${units[i]}`
|
||||
}
|
||||
|
||||
const processColumns: any = [
|
||||
{ title: 'PID', key: 'pid', width: 90 },
|
||||
{ title: $gettext('State'), key: 'state', width: 130, ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Requests'), key: 'requests', width: 100 },
|
||||
{ title: $gettext('Method'), key: 'method', width: 90 },
|
||||
{ title: 'URI', key: 'uri', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: $gettext('Duration (ms)'),
|
||||
key: 'request_duration',
|
||||
width: 150,
|
||||
render: (row: any) => Math.round(row.request_duration / 1000),
|
||||
},
|
||||
{
|
||||
title: $gettext('Memory'),
|
||||
key: 'last_request_memory',
|
||||
width: 110,
|
||||
render: (row: any) => formatBytes(row.last_request_memory),
|
||||
},
|
||||
{ title: $gettext('Script'), key: 'script', minWidth: 250, ellipsis: { tooltip: true } },
|
||||
]
|
||||
|
||||
const moduleColumns: any = [
|
||||
{
|
||||
@@ -181,6 +236,34 @@ const handleUninstallModule = async (module: string) => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetOpcache = async () => {
|
||||
const ok = await confirmAction({
|
||||
type: 'warning',
|
||||
title: $gettext('Confirm Reset'),
|
||||
content: $gettext(
|
||||
'Resetting will clear all cached scripts, performance may fluctuate briefly. Are you sure?',
|
||||
),
|
||||
})
|
||||
if (!ok) return
|
||||
useRequest(php.resetOpcache(slug)).onSuccess(() => {
|
||||
window.$message.success($gettext('Reset successfully'))
|
||||
refreshOpcache()
|
||||
})
|
||||
}
|
||||
|
||||
const handleInstallComposer = async () => {
|
||||
useRequest(php.installComposer(slug)).onSuccess(() => {
|
||||
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveComposerMirror = async () => {
|
||||
useRequest(php.setComposerMirror(slug, composerMirror.value)).onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
refreshComposer()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -257,14 +340,144 @@ const handleUninstallModule = async (module: string) => {
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="400"
|
||||
:loading="false"
|
||||
:columns="loadColumns"
|
||||
:data="load"
|
||||
/>
|
||||
<n-flex vertical>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="400"
|
||||
:loading="false"
|
||||
:columns="loadColumns"
|
||||
:data="load"
|
||||
/>
|
||||
<n-card :title="$gettext('FPM Processes')">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="() => refreshProcesses()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="processColumns"
|
||||
:data="processes"
|
||||
:scroll-x="1170"
|
||||
max-height="50vh"
|
||||
/>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="opcache" tab="OPcache">
|
||||
<n-flex vertical>
|
||||
<n-alert v-if="!opcache.enabled" type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'OPcache is not enabled. Install the Zend OPcache module in Module Management to significantly improve PHP performance.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="() => refreshOpcache()">
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
<n-button type="warning" @click="handleResetOpcache">
|
||||
{{ $gettext('Reset OPcache') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-card :title="$gettext('Cache Statistics')">
|
||||
<n-flex>
|
||||
<n-statistic :label="$gettext('Hit Rate')" :value="`${opcache.hit_rate}%`" />
|
||||
<n-statistic class="ml-40" :label="$gettext('Hits')" :value="opcache.hits" />
|
||||
<n-statistic class="ml-40" :label="$gettext('Misses')" :value="opcache.misses" />
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Cached Scripts')"
|
||||
:value="opcache.cached_scripts"
|
||||
/>
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Cached Keys')"
|
||||
:value="`${opcache.cached_keys} / ${opcache.max_cached_keys}`"
|
||||
/>
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('OOM Restarts')"
|
||||
:value="opcache.oom_restarts"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card :title="$gettext('Memory')">
|
||||
<n-flex>
|
||||
<n-statistic :label="$gettext('Used')" :value="opcache.memory_used" />
|
||||
<n-statistic class="ml-40" :label="$gettext('Free')" :value="opcache.memory_free" />
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Wasted')"
|
||||
:value="`${opcache.memory_wasted} (${opcache.wasted_percent}%)`"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card title="JIT">
|
||||
<n-flex v-if="opcache.jit_enabled">
|
||||
<n-statistic :label="$gettext('Buffer Size')" :value="opcache.jit_buffer_size" />
|
||||
<n-statistic
|
||||
class="ml-40"
|
||||
:label="$gettext('Buffer Free')"
|
||||
:value="opcache.jit_buffer_free"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-text v-else depth="3">{{ $gettext('JIT is not enabled') }}</n-text>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="composer" tab="Composer">
|
||||
<n-flex vertical>
|
||||
<template v-if="!composer.installed">
|
||||
<n-alert type="info">
|
||||
{{ $gettext('Composer is not installed.') }}
|
||||
</n-alert>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="handleInstallComposer">
|
||||
{{ $gettext('Install') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-card :title="$gettext('Composer')">
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="primary" @click="handleInstallComposer">
|
||||
{{ $gettext('Update') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-descriptions label-placement="left" :column="1">
|
||||
<n-descriptions-item :label="$gettext('Version')">
|
||||
{{ composer.version || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
<n-card :title="$gettext('Mirror')">
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'The mirror is a global setting shared by all PHP versions. Use a mirror to speed up package downloads in mainland China.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-flex>
|
||||
<n-select
|
||||
v-model:value="composerMirror"
|
||||
:options="composerMirrorOptions"
|
||||
class="w-80"
|
||||
/>
|
||||
<n-button type="primary" @click="handleSaveComposerMirror">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
|
||||
<realtime-log :service="'php-fpm-' + slug" />
|
||||
|
||||
@@ -541,7 +541,7 @@ watch(
|
||||
{{
|
||||
isPush
|
||||
? $gettext(
|
||||
'The target API must allow this server address. Missing database servers and runtimes are reported in the next step.'
|
||||
'The target address must include the access entrance and allow this server address.'
|
||||
)
|
||||
: $gettext('The source panel API must be enabled and allow this server address.')
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user