diff --git a/internal/request/environment_php.go b/internal/request/environment_php.go
index 4f55a7e0..238ca795 100644
--- a/internal/request/environment_php.go
+++ b/internal/request/environment_php.go
@@ -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"`
diff --git a/internal/route/environment.go b/internal/route/environment.go
index 79cfdf42..e70ca0bf 100644
--- a/internal/route/environment.go
+++ b/internal/route/environment.go
@@ -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,
diff --git a/internal/service/environment_php.go b/internal/service/environment_php.go
index 8f99c328..c4fa9664 100644
--- a/internal/service/environment_php.go
+++ b/internal/service/environment_php.go
@@ -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 := ` 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 ""
}
diff --git a/pkg/fastcgi/fastcgi.go b/pkg/fastcgi/fastcgi.go
new file mode 100644
index 00000000..4c33b3f5
--- /dev/null
+++ b/pkg/fastcgi/fastcgi.go
@@ -0,0 +1,114 @@
+package fastcgi
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net"
+)
+
+const (
+ typeBeginRequest byte = 1
+ typeEndRequest byte = 3
+ typeParams byte = 4
+ typeStdin byte = 5
+ typeStdout byte = 6
+ typeStderr byte = 7
+
+ roleResponder = 1
+)
+
+// Request 向 FastCGI 服务发起一次 responder 请求,返回剥离 CGI 响应头后的 body
+func Request(ctx context.Context, network, address string, params map[string]string) ([]byte, error) {
+ var dialer net.Dialer
+ conn, err := dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = conn.Close() }()
+ if deadline, ok := ctx.Deadline(); ok {
+ _ = conn.SetDeadline(deadline)
+ }
+
+ const requestID = 1
+ // BEGIN_REQUEST:responder 角色,连接不复用
+ if err = writeRecord(conn, typeBeginRequest, requestID, []byte{0, roleResponder, 0, 0, 0, 0, 0, 0}); err != nil {
+ return nil, err
+ }
+ var buf bytes.Buffer
+ for name, value := range params {
+ encodeNameValue(&buf, name, value)
+ }
+ if err = writeRecord(conn, typeParams, requestID, buf.Bytes()); err != nil {
+ return nil, err
+ }
+ if err = writeRecord(conn, typeParams, requestID, nil); err != nil {
+ return nil, err
+ }
+ if err = writeRecord(conn, typeStdin, requestID, nil); err != nil {
+ return nil, err
+ }
+
+ var stdout, stderr bytes.Buffer
+ header := make([]byte, 8)
+ for {
+ if _, err = io.ReadFull(conn, header); err != nil {
+ return nil, err
+ }
+ contentLength := binary.BigEndian.Uint16(header[4:6])
+ content := make([]byte, int(contentLength)+int(header[6]))
+ if _, err = io.ReadFull(conn, content); err != nil {
+ return nil, err
+ }
+ switch header[1] {
+ case typeStdout:
+ stdout.Write(content[:contentLength])
+ case typeStderr:
+ stderr.Write(content[:contentLength])
+ case typeEndRequest:
+ if stderr.Len() > 0 {
+ return nil, fmt.Errorf("fastcgi stderr: %s", stderr.String())
+ }
+ // 剥离 CGI 响应头
+ if _, body, found := bytes.Cut(stdout.Bytes(), []byte("\r\n\r\n")); found {
+ return body, nil
+ }
+ return stdout.Bytes(), nil
+ }
+ }
+}
+
+// writeRecord 写入一条 FastCGI 记录
+func writeRecord(w io.Writer, typ byte, requestID uint16, content []byte) error {
+ header := [8]byte{1, typ}
+ binary.BigEndian.PutUint16(header[2:4], requestID)
+ binary.BigEndian.PutUint16(header[4:6], uint16(len(content))) //nolint:gosec // params 由面板构造,长度远小于 64KB
+ if _, err := w.Write(header[:]); err != nil {
+ return err
+ }
+ if len(content) > 0 {
+ if _, err := w.Write(content); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// encodeNameValue 按 FastCGI name-value 格式编码
+func encodeNameValue(buf *bytes.Buffer, name, value string) {
+ writeLength := func(n int) {
+ if n < 128 {
+ buf.WriteByte(byte(n))
+ return
+ }
+ var b [4]byte
+ binary.BigEndian.PutUint32(b[:], uint32(n)|1<<31) //nolint:gosec // 长度非负
+ buf.Write(b[:])
+ }
+ writeLength(len(name))
+ writeLength(len(value))
+ buf.WriteString(name)
+ buf.WriteString(value)
+}
diff --git a/pkg/types/environment_php.go b/pkg/types/environment_php.go
index a58eeb39..4d839647 100644
--- a/pkg/types/environment_php.go
+++ b/pkg/types/environment_php.go
@@ -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"` // 空为官方源
+}
diff --git a/web/src/api/panel/environment/php/index.ts b/web/src/api/panel/environment/php/index.ts
index edf4fc76..7507d92e 100644
--- a/web/src/api/panel/environment/php/index.ts
+++ b/web/src/api/panel/environment/php/index.ts
@@ -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 }),
}
diff --git a/web/src/views/apps/mysql/MysqlPerformanceView.vue b/web/src/views/apps/mysql/MysqlPerformanceView.vue
index 7eb50b8d..93e42787 100644
--- a/web/src/views/apps/mysql/MysqlPerformanceView.vue
+++ b/web/src/views/apps/mysql/MysqlPerformanceView.vue
@@ -229,23 +229,25 @@ const handleResetTopSQL = async () => {
{{ $gettext('performance_schema is configured, restart the service to take effect.') }}
-
- {{
- $gettext(
- 'performance_schema is not enabled. After enabling, SQL performance statistics will be collected, which increases memory usage and requires a restart.',
- )
- }}
-
- {{ $gettext('Enable') }}
-
-
+
+
+ {{
+ $gettext(
+ 'performance_schema is not enabled. After enabling, SQL performance statistics will be collected, which increases memory usage and requires a restart.',
+ )
+ }}
+
+
+
+ {{ $gettext('Enable') }}
+
+
+
refreshTopSQL()">
diff --git a/web/src/views/apps/postgresql/PostgresqlPerformanceView.vue b/web/src/views/apps/postgresql/PostgresqlPerformanceView.vue
index 2c9a86ea..1e136035 100644
--- a/web/src/views/apps/postgresql/PostgresqlPerformanceView.vue
+++ b/web/src/views/apps/postgresql/PostgresqlPerformanceView.vue
@@ -174,23 +174,25 @@ const handleResetTopSQL = async () => {
$gettext('pg_stat_statements is configured, restart PostgreSQL to take effect.')
}}
-
- {{
- $gettext(
- 'pg_stat_statements is not enabled. After enabling, SQL performance statistics will be collected, a restart of PostgreSQL is required.',
- )
- }}
-
- {{ $gettext('Enable') }}
-
-
+
+
+ {{
+ $gettext(
+ 'pg_stat_statements is not enabled. After enabling, SQL performance statistics will be collected, a restart of PostgreSQL is required.',
+ )
+ }}
+
+
+
+ {{ $gettext('Enable') }}
+
+
+
refreshTopSQL()">
diff --git a/web/src/views/environment/PHPView.vue b/web/src/views/environment/PHPView.vue
index 1b27e432..182100cc 100644
--- a/web/src/views/environment/PHPView.vue
+++ b/web/src/views/environment/PHPView.vue
@@ -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: 130,
+ 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()
+ })
+}
@@ -257,14 +340,144 @@ const handleUninstallModule = async (module: string) => {
-
+
+
+
+
+ refreshProcesses()">
+ {{ $gettext('Refresh') }}
+
+
+
+
+
+
+
+
+
+ {{
+ $gettext(
+ 'OPcache is not enabled. Install the Zend OPcache module in Module Management to significantly improve PHP performance.',
+ )
+ }}
+
+
+
+ refreshOpcache()">
+ {{ $gettext('Refresh') }}
+
+
+ {{ $gettext('Reset OPcache') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $gettext('JIT is not enabled') }}
+
+
+
+
+
+
+
+
+ {{ $gettext('Composer is not installed.') }}
+
+
+
+ {{ $gettext('Install') }}
+
+
+
+
+
+
+
+ {{ $gettext('Update') }}
+
+
+
+
+ {{ composer.version || '-' }}
+
+
+
+
+
+
+ {{
+ $gettext(
+ 'The mirror is a global setting shared by all PHP versions. Use a mirror to speed up package downloads in mainland China.',
+ )
+ }}
+
+
+
+
+ {{ $gettext('Save') }}
+
+
+
+
+
+