feat: PHP 添加 FPM 进程/OPcache/Composer 管理

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-19 22:20:06 +08:00
parent d69caa6aed
commit af23f49811
9 changed files with 855 additions and 194 deletions
+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"`
+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 ""
}
+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)
}
+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"` // 空为官方源
}
@@ -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 }),
}
@@ -229,23 +229,25 @@ const handleResetTopSQL = async () => {
<n-alert v-else-if="!topSQL.enabled && topSQL.pending_restart" type="warning">
{{ $gettext('performance_schema is configured, restart the service to take effect.') }}
</n-alert>
<n-alert v-else-if="!topSQL.enabled" type="info">
{{
$gettext(
'performance_schema is not enabled. After enabling, SQL performance statistics will be collected, which increases memory usage and requires a restart.',
)
}}
<n-button
class="ml-16"
size="small"
type="primary"
:loading="enableTopSQLLoading"
:disabled="enableTopSQLLoading"
@click="handleEnableTopSQL"
>
{{ $gettext('Enable') }}
</n-button>
</n-alert>
<template v-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()">
@@ -174,23 +174,25 @@ const handleResetTopSQL = async () => {
$gettext('pg_stat_statements is configured, restart PostgreSQL to take effect.')
}}
</n-alert>
<n-alert v-else-if="!topSQL.enabled" type="info">
{{
$gettext(
'pg_stat_statements is not enabled. After enabling, SQL performance statistics will be collected, a restart of PostgreSQL is required.',
)
}}
<n-button
class="ml-16"
size="small"
type="primary"
:loading="enableTopSQLLoading"
:disabled="enableTopSQLLoading"
@click="handleEnableTopSQL"
>
{{ $gettext('Enable') }}
</n-button>
</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()">
+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: 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()
})
}
</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="1150"
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" />