Compare commits

...

22 Commits

Author SHA1 Message Date
耗子 a364076e73 fix: 修正数据表格列宽适配中英文文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:31:06 +08:00
github-actions[bot] f1a984deb3 New Crowdin translations by GitHub Action (#1763)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 23:11:00 +08:00
github-actions[bot] ad98ebce57 chore(l10n): update pot files 2026-08-19 14:51:31 +00:00
耗子 3e453f4510 fix: 过滤 MEMORY DOCTOR 无诊断价值的彩蛋文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:48:17 +08:00
耗子 77aa2a3450 feat: Redis/Valkey 添加慢日志/客户端/内存诊断/大Key扫描
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:41:40 +08:00
耗子 0dfe577fc4 feat: Apache 添加参数调优
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:29:44 +08:00
耗子 af23f49811 feat: PHP 添加 FPM 进程/OPcache/Composer 管理
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:20:06 +08:00
耗子 d69caa6aed feat: MySQL 添加进程/事务锁/TopSQL/表维护/binlog/复制运维功能
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:43:36 +08:00
耗子 ecd403a334 feat: PostgreSQL 添加会话/TopSQL/表膨胀/WAL 运维功能
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:12:44 +08:00
github-actions[bot] a729718fd9 chore(l10n): update pot files 2026-08-19 12:42:24 +00:00
耗子 cab0e0ae2b fix: 修复 PostgreSQL 扩展页签翻译词条冲突
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:31:26 +08:00
github-actions[bot] 5c729082a5 New Crowdin translations by GitHub Action (#1762)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 20:26:55 +08:00
github-actions[bot] a7e17c9cbe chore(l10n): update pot files 2026-08-19 11:14:27 +00:00
耗子 6a888b14f6 fix: 精简 PostgreSQL 扩展页面提示文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:12:40 +08:00
github-actions[bot] 5de88d4846 chore(l10n): update pot files 2026-08-19 11:00:31 +00:00
耗子 c5a3858601 feat: PostgreSQL 扩展列表新增 11 个常用扩展
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:55:43 +08:00
耗子 73bc213a0f feat: PostgreSQL 添加扩展管理支持
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:39:34 +08:00
耗子 2dcd3dcdc6 Merge remote-tracking branch 'origin/main' 2026-08-19 18:06:02 +08:00
耗子 4e67b31d40 fix: 修复默认spa配置编号问题 2026-08-19 18:05:54 +08:00
renovate[bot] c1b1278db4 chore(deps): Lock file maintenance (#1761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 05:39:14 +00:00
耗子 5391911a97 fix: 修复日志查看器多处问题
- 首屏加载后不贴底:改由 useResizeObserver 把"跟随时保持贴底"维持成不变式,
  覆盖弹窗入场、横向滚动条、字体度量、tab 由隐藏转可见等后续布局变化
- 向上翻页内容重复:文件日志改用首屏 size 作为反向分页锚点,
  不再受跟踪期间写入的新日志影响
- 首屏到建连之间的日志丢失:follow 支持从锚点字节位置续读(tail -c +N),
  重连仍只跟新增以免整段重放
- 断线重连残留的半行与新流拼接会拼出错行
- 点"跳到顶部"被翻页的位置补偿拽回原处
- 实时裁剪未退还翻页游标,导致下次上翻跳过被裁掉的那一段
- 搜索跳转改按相对位置计算,不再连带滚动外层页面
- 首屏日志不足一页时凭空出现"没有更多日志"

性能:
- ws 帧合并到 rAF 统一落盘,繁忙日志下渲染次数从帧数级降到 60/秒级
- 反向读取按字节切分,只把返回的那页转 string;首块按预估行长一次读足
- 日志行 markRaw 并移除小写副本字段

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:01:12 +08:00
耗子 335fbf697f fix: 优化翻译 2026-08-16 00:26:09 +08:00
59 changed files with 9859 additions and 2310 deletions
+13 -13
View File
@@ -78,7 +78,16 @@ func initAce() (*app.Ace, func(), error) {
giteaApp := gitea.NewApp()
grafanaApp := grafana.NewApp(locale)
kafkaApp := kafka.NewApp(locale)
mysqlApp := mysql.NewApp(locale, databaseServerRepo, settingRepo)
logger, cleanup, err := bootstrap.NewLogger(config)
if err != nil {
return nil, nil, err
}
slogLogger := bootstrap.NewSlog(logger)
notifyChannelRepo := data.NewNotifyChannelRepo(db)
notifyUsecase := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
taskRunner := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
taskRepo := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
mysqlApp := mysql.NewApp(locale, databaseServerRepo, settingRepo, taskRepo)
mariadbApp := mariadb.NewApp(mysqlApp)
memcachedApp := memcached.NewApp(locale)
minioApp := minio.NewApp()
@@ -90,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 {
+85
View File
@@ -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)
}
+16
View File
@@ -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
View File
@@ -1,9 +1,13 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
@@ -25,13 +29,15 @@ type App struct {
t *gotext.Locale
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
}
}
@@ -44,6 +50,19 @@ func (s *App) Route(r chi.Router) {
r.Post("/root_password", s.SetRootPassword)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 性能
r.Get("/processes", s.ProcessList)
r.Post("/processes/{id}/kill", s.KillProcess)
r.Get("/transactions", s.TransactionList)
r.Get("/top_sql", s.TopSQL)
r.Post("/top_sql/enable", s.EnableTopSQL)
r.Post("/top_sql/reset", s.ResetTopSQL)
// 维护
r.Get("/tables", s.TableList)
r.Post("/maintenance", s.RunMaintenance)
r.Get("/binlogs", s.BinlogList)
r.Post("/binlogs/purge", s.PurgeBinlog)
r.Get("/replication", s.ReplicationStatus)
}
func (s *App) Status() string {
@@ -51,38 +70,6 @@ func (s *App) Status() string {
return types.AggregateAppStatus(ok)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
}
// UpdateConfig 保存配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("mysqld"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
return
}
service.Success(w, nil)
}
// Load 获取负载
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, _ := systemctl.Status("mysqld")
@@ -188,6 +175,38 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Success(w, load)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
}
// UpdateConfig 保存配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("mysqld"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
return
}
service.Success(w, nil)
}
// SlowLog 获取慢查询日志
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
service.Success(w, app.Root+"/server/mysql/mysql-slow.log")
@@ -335,3 +354,482 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// ProcessList 获取进程列表
func (s *App) ProcessList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`
SELECT ID, coalesce(USER,''), coalesce(HOST,''), coalesce(DB,''), coalesce(COMMAND,''),
TIME, coalesce(STATE,''), coalesce(INFO,'')
FROM information_schema.PROCESSLIST WHERE ID != CONNECTION_ID() ORDER BY TIME DESC`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
processes := make([]Process, 0)
for rows.Next() {
var item Process
if err = rows.Scan(&item.ID, &item.User, &item.Host, &item.DB, &item.Command, &item.Time, &item.State, &item.Info); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
processes = append(processes, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, processes)
}
// KillProcess 终止进程
func (s *App) KillProcess(w http.ResponseWriter, r *http.Request) {
id := cast.ToInt64(chi.URLParam(r, "id"))
if id <= 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid process id"))
return
}
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
// KILL 不支持预编译参数,id 已校验为正整数
if _, err = mysql.Exec(fmt.Sprintf(`KILL %d`, id)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// TransactionList 获取事务及锁等待列表
func (s *App) TransactionList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`
SELECT trx_id, trx_mysql_thread_id, coalesce(trx_state,''), coalesce(trx_query,''),
timestampdiff(SECOND, trx_started, now()), trx_rows_locked, trx_rows_modified
FROM information_schema.INNODB_TRX ORDER BY trx_started`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
result := Transactions{Transactions: make([]Transaction, 0), LockWaits: make([]LockWait, 0)}
for rows.Next() {
var item Transaction
if err = rows.Scan(&item.ID, &item.ThreadID, &item.State, &item.Query, &item.Seconds, &item.RowsLocked, &item.RowsModified); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
result.Transactions = append(result.Transactions, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 锁等待对,MariaDB 与 MySQL 8 的表不同,查询失败时忽略
lockSQL := `
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID`
if s.isMariaDB(mysql) {
lockSQL = `
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id`
}
if lockRows, lockErr := mysql.Query(lockSQL); lockErr == nil {
defer func() { _ = lockRows.Close() }()
for lockRows.Next() {
var item LockWait
if err = lockRows.Scan(&item.WaitingThreadID, &item.WaitingQuery, &item.BlockingThreadID, &item.BlockingQuery); err != nil {
break
}
result.LockWaits = append(result.LockWaits, item)
}
}
service.Success(w, result)
}
// TopSQL 获取 SQL 性能统计
func (s *App) TopSQL(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
if strings.Contains(err.Error(), "Unknown system variable") {
service.Success(w, TopSQL{Supported: false, Items: []TopSQLItem{}})
return
}
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if enabled == 0 {
// 检查是否已配置等待重启
config, _ := io.Read(app.Root + "/server/mysql/conf/my.cnf")
pending := strings.EqualFold(confval.SectionINI.GetIn(config, "mysqld", "performance_schema"), "on")
service.Success(w, TopSQL{Supported: true, Enabled: false, PendingRestart: pending, Items: []TopSQLItem{}})
return
}
rows, err := mysql.Query(`
SELECT coalesce(SCHEMA_NAME,''), COUNT_STAR, round(SUM_TIMER_WAIT/1e9),
round(AVG_TIMER_WAIT/1e9,2), SUM_ROWS_SENT, SUM_ROWS_EXAMINED, coalesce(DIGEST_TEXT,'')
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT IS NOT NULL ORDER BY SUM_TIMER_WAIT DESC LIMIT 50`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]TopSQLItem, 0)
for rows.Next() {
var item TopSQLItem
if err = rows.Scan(&item.Database, &item.Calls, &item.TotalMs, &item.MeanMs, &item.RowsSent, &item.RowsExamined, &item.Query); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, TopSQL{Supported: true, Enabled: true, Items: items})
}
// EnableTopSQL 启用 performance_schema
func (s *App) EnableTopSQL(w http.ResponseWriter, r *http.Request) {
// 实例不支持时禁止写入配置
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("performance_schema is not supported by this instance"))
return
}
confPath := app.Root + "/server/mysql/conf/my.cnf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// my.cnf 分段,必须用 SectionINI 保证写入 [mysqld] 段内,重启后生效
config = confval.SectionINI.SetIn(config, "mysqld", "performance_schema", "on")
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ResetTopSQL 重置 SQL 性能统计
func (s *App) ResetTopSQL(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
if _, err = mysql.Exec(`TRUNCATE TABLE performance_schema.events_statements_summary_by_digest`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// TableList 获取表维护信息
func (s *App) TableList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`
SELECT table_schema, table_name, coalesce(engine,''), coalesce(table_rows,0),
coalesce(data_length + index_length,0), coalesce(data_free,0)
FROM information_schema.TABLES
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
AND table_type = 'BASE TABLE'
ORDER BY data_length + index_length DESC LIMIT 50`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]TableInfo, 0)
for rows.Next() {
var item TableInfo
var size, free int64
if err = rows.Scan(&item.Database, &item.Table, &item.Engine, &item.Rows, &size, &free); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
item.Size = tools.FormatBytes(float64(size))
if size+free > 0 {
item.FragmentRate = float64(free) * 100 / float64(size+free)
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, items)
}
// RunMaintenance 对表执行维护操作(异步任务)
func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[MaintenanceRun](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !slices.Contains([]string{"optimize", "analyze"}, req.Operation) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
return
}
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
escaped := strings.ReplaceAll(rootPassword, `'`, `'\''`)
cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE `%s`.`%s`'", escaped, strings.ToUpper(req.Operation), req.Database, req.Table)
task := new(biz.Task)
task.Key = fmt.Sprintf("mysql:maintenance:%s.%s", req.Database, req.Table)
task.Name = s.t.Get("Run %s on table %s.%s", req.Operation, req.Database, req.Table)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// BinlogList 获取 binlog 状态
func (s *App) BinlogList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@log_bin`).Scan(&enabled); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if enabled == 0 {
service.Success(w, Binlog{Enabled: false, Items: []BinlogFile{}})
return
}
rows, err := mysql.Query(`SHOW BINARY LOGS`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// SHOW BINARY LOGS 列数两派不同(MySQL 8 多 Encrypted 列),动态取列
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
binlog := Binlog{Enabled: true, Items: make([]BinlogFile, 0, len(maps))}
var total int64
for _, m := range maps {
size := cast.ToInt64(m["File_size"])
total += size
binlog.Items = append(binlog.Items, BinlogFile{Name: m["Log_name"], Size: tools.FormatBytes(float64(size))})
}
binlog.TotalSize = tools.FormatBytes(float64(total))
service.Success(w, binlog)
}
// PurgeBinlog 清理指定文件之前的 binlog
func (s *App) PurgeBinlog(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[BinlogPurge](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
// 校验文件名存在于 binlog 列表,PURGE 不支持预编译参数
rows, err := mysql.Query(`SHOW BINARY LOGS`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !slices.ContainsFunc(maps, func(m map[string]string) bool { return m["Log_name"] == req.File }) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("binlog file %s does not exist", req.File))
return
}
if _, err = mysql.Exec(fmt.Sprintf(`PURGE BINARY LOGS TO '%s'`, req.File)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ReplicationStatus 获取复制状态
func (s *App) ReplicationStatus(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`SHOW REPLICA STATUS`)
if err != nil {
// 老版本 fallback
if rows, err = mysql.Query(`SHOW SLAVE STATUS`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if len(maps) == 0 {
service.Success(w, Replication{Enabled: false})
return
}
// MySQL 8 与 MariaDB 的列名两派不同,按候选名取值
pick := func(m map[string]string, keys ...string) string {
for _, key := range keys {
if v, ok := m[key]; ok && v != "" {
return v
}
}
return ""
}
m := maps[0]
service.Success(w, Replication{
Enabled: true,
IORunning: pick(m, "Replica_IO_Running", "Slave_IO_Running"),
SQLRunning: pick(m, "Replica_SQL_Running", "Slave_SQL_Running"),
SecondsBehind: pick(m, "Seconds_Behind_Source", "Seconds_Behind_Master"),
SourceHost: pick(m, "Source_Host", "Master_Host"),
LastError: pick(m, "Last_Error"),
})
}
// connect 以 root 用户通过 unix socket 连接
func (s *App) connect(ctx context.Context) (db.Operator, error) {
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
if err != nil {
return nil, err
}
return db.NewMySQL(ctx, "root", rootPassword, db.MySQLSocket(app.Root), "unix")
}
// isMariaDB 判断当前实例是否为 MariaDB
func (s *App) isMariaDB(op db.Operator) bool {
var version string
if err := op.QueryRow(`SELECT VERSION()`).Scan(&version); err != nil {
return false
}
return strings.Contains(strings.ToLower(version), "mariadb")
}
// rowsToMaps 将查询结果按列名转为 map,用于列名/列数不固定的查询
func (s *App) rowsToMaps(rows *sql.Rows) ([]map[string]string, error) {
defer func() { _ = rows.Close() }()
columns, err := rows.Columns()
if err != nil {
return nil, err
}
result := make([]map[string]string, 0)
for rows.Next() {
values := make([]sql.NullString, len(columns))
scans := make([]any, len(columns))
for i := range values {
scans[i] = &values[i]
}
if err = rows.Scan(scans...); err != nil {
return nil, err
}
m := make(map[string]string, len(columns))
for i, column := range columns {
m[column] = values[i].String
}
result = append(result, m)
}
return result, rows.Err()
}
+101
View File
@@ -41,3 +41,104 @@ type ConfigTune struct {
SlowQueryLog string `form:"slow_query_log" json:"slow_query_log"`
LongQueryTime string `form:"long_query_time" json:"long_query_time"`
}
// MaintenanceRun 表维护操作请求
type MaintenanceRun struct {
Database string `form:"database" json:"database" validate:"required"`
Table string `form:"table" json:"table" validate:"required"`
Operation string `form:"operation" json:"operation" validate:"required"`
}
// BinlogPurge binlog 清理请求
type BinlogPurge struct {
File string `form:"file" json:"file" validate:"required"`
}
// Process 数据库进程信息
type Process struct {
ID int64 `json:"id"`
User string `json:"user"`
Host string `json:"host"`
DB string `json:"db"`
Command string `json:"command"`
Time int64 `json:"time"`
State string `json:"state"`
Info string `json:"info"`
}
// Transaction InnoDB 事务信息
type Transaction struct {
ID string `json:"id"`
ThreadID int64 `json:"thread_id"`
State string `json:"state"`
Query string `json:"query"`
Seconds int64 `json:"seconds"`
RowsLocked int64 `json:"rows_locked"`
RowsModified int64 `json:"rows_modified"`
}
// LockWait 锁等待对
type LockWait struct {
WaitingThreadID int64 `json:"waiting_thread_id"`
WaitingQuery string `json:"waiting_query"`
BlockingThreadID int64 `json:"blocking_thread_id"`
BlockingQuery string `json:"blocking_query"`
}
// Transactions 事务与锁等待响应
type Transactions struct {
Transactions []Transaction `json:"transactions"`
LockWaits []LockWait `json:"lock_waits"`
}
// TopSQLItem Top SQL 统计项
type TopSQLItem struct {
Database string `json:"database"`
Calls int64 `json:"calls"`
TotalMs int64 `json:"total_ms"`
MeanMs float64 `json:"mean_ms"`
RowsSent int64 `json:"rows_sent"`
RowsExamined int64 `json:"rows_examined"`
Query string `json:"query"`
}
// TopSQL Top SQL 响应
type TopSQL struct {
Supported bool `json:"supported"`
Enabled bool `json:"enabled"`
PendingRestart bool `json:"pending_restart"`
Items []TopSQLItem `json:"items"`
}
// TableInfo 表维护信息
type TableInfo struct {
Database string `json:"database"`
Table string `json:"table"`
Engine string `json:"engine"`
Rows int64 `json:"rows"`
Size string `json:"size"`
FragmentRate float64 `json:"fragment_rate"`
}
// BinlogFile binlog 文件信息
type BinlogFile struct {
Name string `json:"name"`
Size string `json:"size"`
}
// Binlog binlog 状态响应
type Binlog struct {
Enabled bool `json:"enabled"`
TotalSize string `json:"total_size"`
Items []BinlogFile `json:"items"`
}
// Replication 复制状态响应
type Replication struct {
Enabled bool `json:"enabled"`
IORunning string `json:"io_running"`
SQLRunning string `json:"sql_running"`
SecondsBehind string `json:"seconds_behind"`
SourceHost string `json:"source_host"`
LastError string `json:"last_error"`
}
+554 -6
View File
@@ -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"
}
+119
View File
@@ -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
View File
@@ -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")
}
+33
View File
@@ -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"`
}
+2 -2
View File
@@ -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),
}
}
+7 -6
View File
@@ -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
}
+6
View File
@@ -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"`
+4
View File
@@ -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 {
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-60
View File
@@ -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) {}
+5
View File
@@ -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())
+3
View File
@@ -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)
}
-2
View File
@@ -1,5 +1,3 @@
//go:build !windows
// Package chattr https://github.com/g0rbe/go-chattr/pull/3
package chattr
-115
View File
@@ -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
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -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_REQUESTresponder 角色,连接不复用
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
View File
@@ -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)
}
+40
View File
@@ -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"` // 空为官方源
}
+20 -20
View File
@@ -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
+4
View File
@@ -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),
}
+13
View File
@@ -18,4 +18,17 @@ export default {
configTune: (): any => http.Get('/apps/mariadb/config_tune'),
// 保存配置调整参数
saveConfigTune: (data: any): any => http.Post('/apps/mariadb/config_tune', data),
// 性能
processes: (): any => http.Get('/apps/mariadb/processes'),
killProcess: (id: number): any => http.Post(`/apps/mariadb/processes/${id}/kill`),
transactions: (): any => http.Get('/apps/mariadb/transactions'),
topSQL: (): any => http.Get('/apps/mariadb/top_sql'),
enableTopSQL: (): any => http.Post('/apps/mariadb/top_sql/enable'),
resetTopSQL: (): any => http.Post('/apps/mariadb/top_sql/reset'),
// 维护
tables: (): any => http.Get('/apps/mariadb/tables'),
runMaintenance: (data: any): any => http.Post('/apps/mariadb/maintenance', data),
binlogs: (): any => http.Get('/apps/mariadb/binlogs'),
purgeBinlog: (file: string): any => http.Post('/apps/mariadb/binlogs/purge', { file }),
replication: (): any => http.Get('/apps/mariadb/replication'),
}
+13
View File
@@ -17,4 +17,17 @@ export default {
configTune: (): any => http.Get('/apps/mysql/config_tune'),
// 保存配置调整参数
saveConfigTune: (data: any): any => http.Post('/apps/mysql/config_tune', data),
// 性能
processes: (): any => http.Get('/apps/mysql/processes'),
killProcess: (id: number): any => http.Post(`/apps/mysql/processes/${id}/kill`),
transactions: (): any => http.Get('/apps/mysql/transactions'),
topSQL: (): any => http.Get('/apps/mysql/top_sql'),
enableTopSQL: (): any => http.Post('/apps/mysql/top_sql/enable'),
resetTopSQL: (): any => http.Post('/apps/mysql/top_sql/reset'),
// 维护
tables: (): any => http.Get('/apps/mysql/tables'),
runMaintenance: (data: any): any => http.Post('/apps/mysql/maintenance', data),
binlogs: (): any => http.Get('/apps/mysql/binlogs'),
purgeBinlog: (file: string): any => http.Post('/apps/mysql/binlogs/purge', { file }),
replication: (): any => http.Get('/apps/mysql/replication'),
}
+13
View File
@@ -18,4 +18,17 @@ export default {
configTune: (): any => http.Get('/apps/percona/config_tune'),
// 保存配置调整参数
saveConfigTune: (data: any): any => http.Post('/apps/percona/config_tune', data),
// 性能
processes: (): any => http.Get('/apps/percona/processes'),
killProcess: (id: number): any => http.Post(`/apps/percona/processes/${id}/kill`),
transactions: (): any => http.Get('/apps/percona/transactions'),
topSQL: (): any => http.Get('/apps/percona/top_sql'),
enableTopSQL: (): any => http.Post('/apps/percona/top_sql/enable'),
resetTopSQL: (): any => http.Post('/apps/percona/top_sql/reset'),
// 维护
tables: (): any => http.Get('/apps/percona/tables'),
runMaintenance: (data: any): any => http.Post('/apps/percona/maintenance', data),
binlogs: (): any => http.Get('/apps/percona/binlogs'),
purgeBinlog: (file: string): any => http.Post('/apps/percona/binlogs/purge', { file }),
replication: (): any => http.Get('/apps/percona/replication'),
}
+19
View File
@@ -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}`),
}
+10
View File
@@ -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'),
}
+10
View File
@@ -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 }),
}
+2
View File
@@ -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 }),
+8 -2
View File
@@ -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)
+104 -56
View File
@@ -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">
+11 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1043 -299
View File
File diff suppressed because it is too large Load Diff
+1036 -292
View File
File diff suppressed because it is too large Load Diff
+1036 -292
View File
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>
+5
View File
@@ -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
+8
View File
@@ -9,6 +9,8 @@ import systemctl from '@/api/panel/systemctl'
import ServiceStatus from '@/components/common/ServiceStatus.vue'
import MysqlConfigTuneView from './MysqlConfigTuneView.vue'
import MysqlMaintenanceView from './MysqlMaintenanceView.vue'
import MysqlPerformanceView from './MysqlPerformanceView.vue'
const props = defineProps<{
api: typeof mysql
@@ -173,6 +175,12 @@ const handleCopyRootPassword = () => {
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
<mysql-config-tune-view :api="props.api" />
</n-tab-pane>
<n-tab-pane name="performance" :tab="$gettext('Performance')">
<mysql-performance-view :api="props.api" />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="$gettext('Maintenance')">
<mysql-maintenance-view :api="props.api" />
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { NButton, NSpace, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import type mysql from '@/api/apps/mysql'
import { useConfirm } from '@/components/system/composables/useConfirm'
const props = defineProps<{
api: typeof mysql
}>()
const { $gettext } = useGettext()
const { confirmDelete, confirmAction } = useConfirm()
const { data: tables, send: refreshTables } = useRequest(props.api.tables, {
initialData: [],
})
const { data: binlog, send: refreshBinlogs } = useRequest(props.api.binlogs, {
initialData: { enabled: true, total_size: '-', items: [] },
})
const { data: replication, send: refreshReplication } = useRequest(props.api.replication, {
initialData: { enabled: false },
})
const handleMaintenance = async (row: any, operation: string) => {
const content =
operation === 'optimize'
? $gettext(
'OPTIMIZE will rebuild the InnoDB table, which may take a long time for large tables. Are you sure you want to run it on %{ table }?',
{ table: `${row.database}.${row.table}` },
)
: $gettext('Are you sure you want to run %{ op } on %{ table }?', {
op: operation.toUpperCase(),
table: `${row.database}.${row.table}`,
})
const ok = await confirmAction({
type: 'warning',
title: $gettext('Confirm Operation'),
content,
})
if (!ok) return
useRequest(
props.api.runMaintenance({
database: row.database,
table: row.table,
operation,
}),
).onSuccess(() => {
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
})
}
const tableColumns: any = [
{ title: $gettext('Database'), key: 'database', width: 130, ellipsis: { tooltip: true } },
{ title: $gettext('Table'), key: 'table', minWidth: 150, ellipsis: { tooltip: true } },
{ title: $gettext('Engine'), key: 'engine', width: 100 },
{ title: $gettext('Rows'), key: 'rows', width: 110 },
{ title: $gettext('Size'), key: 'size', width: 100 },
{
title: $gettext('Fragment Rate'),
key: 'fragment_rate',
width: 130,
render(row: any) {
const rate = Math.round(row.fragment_rate * 10) / 10
const type = rate >= 30 ? 'error' : rate >= 10 ? 'warning' : 'default'
return h(NTag, { type, size: 'small' }, { default: () => `${rate}%` })
},
},
{
title: $gettext('Actions'),
key: 'actions',
width: 230,
render(row: any) {
return h(NSpace, { size: 'small', wrap: false }, {
default: () => [
h(
NButton,
{ size: 'small', type: 'warning', onClick: () => handleMaintenance(row, 'optimize') },
{ default: () => 'OPTIMIZE' },
),
h(
NButton,
{ size: 'small', onClick: () => handleMaintenance(row, 'analyze') },
{ default: () => 'ANALYZE' },
),
],
})
},
},
]
const binlogColumns: any = [
{ title: $gettext('File'), key: 'name', minWidth: 200 },
{ title: $gettext('Size'), key: 'size', width: 120 },
{
title: $gettext('Actions'),
key: 'actions',
width: 160,
render(row: any) {
return h(
NButton,
{
size: 'small',
type: 'error',
onClick: async () => {
const ok = await confirmDelete({
title: $gettext('Confirm Purge'),
content: $gettext(
'This will delete all binlogs before %{ file }. If a replica has not applied them yet, replication will break. Are you sure?',
{ file: row.name },
),
positiveText: $gettext('Purge'),
countdown: 5,
})
if (ok) handlePurge(row.name)
},
},
{ default: () => $gettext('Purge to Here') },
)
},
},
]
const handlePurge = (file: string) => {
useRequest(props.api.purgeBinlog(file)).onSuccess(() => {
window.$message.success($gettext('Purged successfully'))
refreshBinlogs()
})
}
const replicationRunning = (value: string) => {
return value === 'Yes'
? h(NTag, { type: 'success', size: 'small' }, { default: () => $gettext('Running') })
: h(NTag, { type: 'error', size: 'small' }, { default: () => value || $gettext('Stopped') })
}
</script>
<template>
<n-tabs type="segment" animated>
<n-tab-pane name="tables" :tab="$gettext('Table Maintenance')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="() => refreshTables()">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
<n-data-table
striped
:columns="tableColumns"
:data="tables"
:scroll-x="990"
max-height="60vh"
/>
</n-flex>
</n-tab-pane>
<n-tab-pane name="binlog" :tab="'Binlog'">
<n-flex vertical>
<n-alert v-if="!binlog.enabled" type="info">
{{ $gettext('Binary log is not enabled.') }}
</n-alert>
<template v-else>
<n-flex>
<n-button type="primary" @click="() => refreshBinlogs()">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
<n-card>
<n-flex>
<n-statistic :label="$gettext('File Count')" :value="binlog.items.length" />
<n-statistic class="ml-40" :label="$gettext('Total Size')" :value="binlog.total_size" />
</n-flex>
</n-card>
<n-data-table
striped
:columns="binlogColumns"
:data="binlog.items"
:scroll-x="520"
max-height="50vh"
/>
</template>
</n-flex>
</n-tab-pane>
<n-tab-pane name="replication" :tab="$gettext('Replication')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="() => refreshReplication()">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
<n-alert v-if="!replication.enabled" type="info">
{{ $gettext('This instance is not a replica.') }}
</n-alert>
<n-card v-else :title="$gettext('Replication Status')">
<n-descriptions label-placement="left" :column="2">
<n-descriptions-item :label="$gettext('Source Host')">
{{ replication.source_host || '-' }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Delay (s)')">
{{ replication.seconds_behind || '-' }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('IO Thread')">
<component :is="replicationRunning(replication.io_running)" />
</n-descriptions-item>
<n-descriptions-item :label="$gettext('SQL Thread')">
<component :is="replicationRunning(replication.sql_running)" />
</n-descriptions-item>
<n-descriptions-item v-if="replication.last_error" :label="$gettext('Last Error')" :span="2">
{{ replication.last_error }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
@@ -0,0 +1,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 -1
View File
@@ -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>
+5
View File
@@ -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>
+5
View File
@@ -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
+221 -8
View File
@@ -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" />
+1 -1
View File
@@ -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.')
}}