mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 07:57:21 +08:00
feat: 面板工具箱新增日志清理工具 (#1228)
* Initial plan * feat: 添加日志清理工具 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 完成日志清理工具实现 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 优化日志清理 * feat: 添加 Podman 支持并修复 MySQL 日志清理 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 优化日志清理 * fix: lint --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
@@ -122,6 +122,7 @@ func initWeb() (*app.Web, error) {
|
||||
toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale)
|
||||
toolboxSSHService := service.NewToolboxSSHService(locale)
|
||||
toolboxDiskService := service.NewToolboxDiskService(locale)
|
||||
toolboxLogService := service.NewToolboxLogService(locale, db, containerImageRepo, settingRepo)
|
||||
webHookRepo := data.NewWebHookRepo(locale, db)
|
||||
webHookService := service.NewWebHookService(webHookRepo)
|
||||
codeserverApp := codeserver.NewApp()
|
||||
@@ -145,7 +146,7 @@ func initWeb() (*app.Web, error) {
|
||||
s3fsApp := s3fs.NewApp(locale)
|
||||
supervisorApp := supervisor.NewApp(locale)
|
||||
loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
|
||||
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, webHookService, loader)
|
||||
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, webHookService, loader)
|
||||
wsService := service.NewWsService(locale, config, logger, sshRepo)
|
||||
ws := route.NewWs(wsService)
|
||||
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
|
||||
|
||||
7
go.sum
7
go.sum
@@ -122,6 +122,8 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
@@ -271,6 +273,7 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -379,6 +382,8 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -451,6 +456,8 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
|
||||
6
internal/http/request/toolbox_log.go
Normal file
6
internal/http/request/toolbox_log.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package request
|
||||
|
||||
// ToolboxLogClean 日志清理请求
|
||||
type ToolboxLogClean struct {
|
||||
Type string `form:"type" json:"type" validate:"required|in:panel,website,mysql,docker,system"`
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type Http struct {
|
||||
toolboxBenchmark *service.ToolboxBenchmarkService
|
||||
toolboxSSH *service.ToolboxSSHService
|
||||
toolboxDisk *service.ToolboxDiskService
|
||||
toolboxLog *service.ToolboxLogService
|
||||
webhook *service.WebHookService
|
||||
apps *apploader.Loader
|
||||
}
|
||||
@@ -91,6 +92,7 @@ func NewHttp(
|
||||
toolboxBenchmark *service.ToolboxBenchmarkService,
|
||||
toolboxSSH *service.ToolboxSSHService,
|
||||
toolboxDisk *service.ToolboxDiskService,
|
||||
toolboxLog *service.ToolboxLogService,
|
||||
webhook *service.WebHookService,
|
||||
apps *apploader.Loader,
|
||||
) *Http {
|
||||
@@ -130,6 +132,7 @@ func NewHttp(
|
||||
toolboxBenchmark: toolboxBenchmark,
|
||||
toolboxSSH: toolboxSSH,
|
||||
toolboxDisk: toolboxDisk,
|
||||
toolboxLog: toolboxLog,
|
||||
webhook: webhook,
|
||||
apps: apps,
|
||||
}
|
||||
@@ -497,6 +500,11 @@ func (route *Http) Register(r *chi.Mux) {
|
||||
r.Post("/lvm/lv/extend", route.toolboxDisk.ExtendLV)
|
||||
})
|
||||
|
||||
r.Route("/toolbox_log", func(r chi.Router) {
|
||||
r.Get("/scan", route.toolboxLog.Scan)
|
||||
r.Post("/clean", route.toolboxLog.Clean)
|
||||
})
|
||||
|
||||
r.Route("/webhook", func(r chi.Router) {
|
||||
r.Get("/", route.webhook.List)
|
||||
r.Post("/", route.webhook.Create)
|
||||
|
||||
@@ -40,5 +40,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewToolboxBenchmarkService,
|
||||
NewToolboxSSHService,
|
||||
NewToolboxDiskService,
|
||||
NewToolboxLogService,
|
||||
NewWsService,
|
||||
)
|
||||
|
||||
652
internal/service/toolbox_log.go
Normal file
652
internal/service/toolbox_log.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/libtnb/chix"
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/acepanel/panel/internal/app"
|
||||
"github.com/acepanel/panel/internal/biz"
|
||||
"github.com/acepanel/panel/internal/http/request"
|
||||
"github.com/acepanel/panel/pkg/io"
|
||||
"github.com/acepanel/panel/pkg/shell"
|
||||
"github.com/acepanel/panel/pkg/tools"
|
||||
)
|
||||
|
||||
type ToolboxLogService struct {
|
||||
t *gotext.Locale
|
||||
db *gorm.DB
|
||||
containerImageRepo biz.ContainerImageRepo
|
||||
settingRepo biz.SettingRepo
|
||||
}
|
||||
|
||||
func NewToolboxLogService(t *gotext.Locale, db *gorm.DB, containerImageRepo biz.ContainerImageRepo, settingRepo biz.SettingRepo) *ToolboxLogService {
|
||||
return &ToolboxLogService{
|
||||
t: t,
|
||||
db: db,
|
||||
containerImageRepo: containerImageRepo,
|
||||
settingRepo: settingRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// LogItem 日志项信息
|
||||
type LogItem struct {
|
||||
Name string `json:"name"` // 日志名称
|
||||
Path string `json:"path"` // 日志路径
|
||||
Size string `json:"size"` // 日志大小
|
||||
}
|
||||
|
||||
// Scan 扫描日志
|
||||
func (s *ToolboxLogService) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ToolboxLogClean](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var items []LogItem
|
||||
|
||||
switch req.Type {
|
||||
case "panel":
|
||||
items = s.scanPanelLogs()
|
||||
case "website":
|
||||
items = s.scanWebsiteLogs()
|
||||
case "mysql":
|
||||
items = s.scanMySQLLogs()
|
||||
case "docker":
|
||||
items = s.scanDockerLogs()
|
||||
case "system":
|
||||
items = s.scanSystemLogs()
|
||||
default:
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("unknown log type"))
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, items)
|
||||
}
|
||||
|
||||
// Clean 清理日志
|
||||
func (s *ToolboxLogService) Clean(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ToolboxLogClean](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var cleaned int64
|
||||
var cleanErr error
|
||||
|
||||
switch req.Type {
|
||||
case "panel":
|
||||
cleaned, cleanErr = s.cleanPanelLogs()
|
||||
case "website":
|
||||
cleaned, cleanErr = s.cleanWebsiteLogs()
|
||||
case "mysql":
|
||||
cleaned, cleanErr = s.cleanMySQLLogs()
|
||||
case "docker":
|
||||
cleaned, cleanErr = s.cleanDockerLogs()
|
||||
case "system":
|
||||
cleaned, cleanErr = s.cleanSystemLogs()
|
||||
default:
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("unknown log type"))
|
||||
return
|
||||
}
|
||||
|
||||
if cleanErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", cleanErr)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, chix.M{
|
||||
"cleaned": tools.FormatBytes(float64(cleaned)),
|
||||
})
|
||||
}
|
||||
|
||||
// scanPanelLogs 扫描面板日志
|
||||
func (s *ToolboxLogService) scanPanelLogs() []LogItem {
|
||||
var items []LogItem
|
||||
logPath := filepath.Join(app.Root, "panel/storage/logs")
|
||||
|
||||
if !io.Exists(logPath) {
|
||||
return items
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, LogItem{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(logPath, entry.Name()),
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// scanWebsiteLogs 扫描网站日志
|
||||
func (s *ToolboxLogService) scanWebsiteLogs() []LogItem {
|
||||
var items []LogItem
|
||||
sitesPath := filepath.Join(app.Root, "sites")
|
||||
|
||||
if !io.Exists(sitesPath) {
|
||||
return items
|
||||
}
|
||||
|
||||
// 获取所有网站
|
||||
websites := make([]*biz.Website, 0)
|
||||
if err := s.db.Find(&websites).Error; err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, website := range websites {
|
||||
logPath := filepath.Join(sitesPath, website.Name, "log")
|
||||
if !io.Exists(logPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, LogItem{
|
||||
Name: fmt.Sprintf("%s - %s", website.Name, entry.Name()),
|
||||
Path: filepath.Join(logPath, entry.Name()),
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// scanMySQLLogs 扫描 MySQL 日志
|
||||
func (s *ToolboxLogService) scanMySQLLogs() []LogItem {
|
||||
var items []LogItem
|
||||
mysqlPath := filepath.Join(app.Root, "server/mysql")
|
||||
|
||||
if !io.Exists(mysqlPath) {
|
||||
return items
|
||||
}
|
||||
|
||||
// 慢查询日志
|
||||
slowLogPath := filepath.Join(mysqlPath, "mysql-slow.log")
|
||||
if io.Exists(slowLogPath) {
|
||||
if info, err := os.Stat(slowLogPath); err == nil {
|
||||
items = append(items, LogItem{
|
||||
Name: "mysql-slow.log",
|
||||
Path: slowLogPath,
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 二进制日志
|
||||
entries, err := os.ReadDir(mysqlPath)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
binLogRegex := regexp.MustCompile(`^mysql-bin\.\d+$`)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if binLogRegex.MatchString(entry.Name()) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, LogItem{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(mysqlPath, entry.Name()),
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// scanDockerLogs 扫描 Docker/Podman 相关内容
|
||||
func (s *ToolboxLogService) scanDockerLogs() []LogItem {
|
||||
var items []LogItem
|
||||
|
||||
// 未使用的容器镜像 (Docker)
|
||||
images, err := s.containerImageRepo.List()
|
||||
if err == nil {
|
||||
// 计算未使用的镜像
|
||||
var unusedCount int
|
||||
for _, img := range images {
|
||||
if img.Containers == 0 {
|
||||
unusedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if unusedCount > 0 {
|
||||
items = append(items, LogItem{
|
||||
Name: s.t.Get("Unused container images: %d", unusedCount),
|
||||
Path: "docker:images",
|
||||
Size: s.t.Get("%d images", unusedCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Docker 容器日志路径
|
||||
dockerLogPath := "/var/lib/docker/containers"
|
||||
if io.Exists(dockerLogPath) {
|
||||
totalSize, logCount := s.scanContainerLogDir(dockerLogPath)
|
||||
if logCount > 0 {
|
||||
items = append(items, LogItem{
|
||||
Name: s.t.Get("Docker container logs: %d files", logCount),
|
||||
Path: "docker:logs",
|
||||
Size: tools.FormatBytes(float64(totalSize)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Podman 容器日志路径
|
||||
podmanLogPaths := []string{
|
||||
"/var/lib/containers/storage/overlay-containers",
|
||||
"/run/containers/storage/overlay-containers",
|
||||
}
|
||||
for _, podmanLogPath := range podmanLogPaths {
|
||||
if io.Exists(podmanLogPath) {
|
||||
totalSize, logCount := s.scanContainerLogDir(podmanLogPath)
|
||||
if logCount > 0 {
|
||||
items = append(items, LogItem{
|
||||
Name: s.t.Get("Podman container logs: %d files", logCount),
|
||||
Path: "podman:logs",
|
||||
Size: tools.FormatBytes(float64(totalSize)),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// scanContainerLogDir 扫描容器日志目录
|
||||
func (s *ToolboxLogService) scanContainerLogDir(logPath string) (int64, int) {
|
||||
var totalSize int64
|
||||
var logCount int
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
containerPath := filepath.Join(logPath, entry.Name())
|
||||
// 扫描 *.log 文件
|
||||
logFiles, _ := filepath.Glob(filepath.Join(containerPath, "*.log"))
|
||||
for _, logFile := range logFiles {
|
||||
if info, err := os.Stat(logFile); err == nil {
|
||||
totalSize += info.Size()
|
||||
logCount++
|
||||
}
|
||||
}
|
||||
// 扫描 userdata 子目录下的日志 (Podman)
|
||||
userdataPath := filepath.Join(containerPath, "userdata")
|
||||
if io.Exists(userdataPath) {
|
||||
userdataLogs, _ := filepath.Glob(filepath.Join(userdataPath, "*.log"))
|
||||
for _, logFile := range userdataLogs {
|
||||
if info, err := os.Stat(logFile); err == nil {
|
||||
totalSize += info.Size()
|
||||
logCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize, logCount
|
||||
}
|
||||
|
||||
// scanSystemLogs 扫描系统日志
|
||||
func (s *ToolboxLogService) scanSystemLogs() []LogItem {
|
||||
var items []LogItem
|
||||
|
||||
logFiles := []string{
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dmesg",
|
||||
"/var/log/btmp",
|
||||
"/var/log/wtmp",
|
||||
"/var/log/lastlog",
|
||||
}
|
||||
|
||||
for _, logFile := range logFiles {
|
||||
if !io.Exists(logFile) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(logFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, LogItem{
|
||||
Name: filepath.Base(logFile),
|
||||
Path: logFile,
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
|
||||
// /var/log/*.log 文件
|
||||
logPattern := "/var/log/*.log"
|
||||
matches, _ := filepath.Glob(logPattern)
|
||||
for _, match := range matches {
|
||||
// 跳过已经添加的文件
|
||||
if lo.Contains(logFiles, match) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, LogItem{
|
||||
Name: filepath.Base(match),
|
||||
Path: match,
|
||||
Size: tools.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
|
||||
// journal 日志大小
|
||||
journalOutput, _ := shell.Execf("journalctl --disk-usage 2>/dev/null | grep -oP '\\d+\\.?\\d*[KMGT]?' || echo '0'")
|
||||
journalSize := strings.TrimSpace(journalOutput)
|
||||
if journalSize != "" && journalSize != "0" {
|
||||
items = append(items, LogItem{
|
||||
Name: s.t.Get("Journal logs"),
|
||||
Path: "system:journal",
|
||||
Size: journalSize,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// cleanPanelLogs 清理面板日志
|
||||
func (s *ToolboxLogService) cleanPanelLogs() (int64, error) {
|
||||
var cleaned int64
|
||||
logPath := filepath.Join(app.Root, "panel/storage/logs")
|
||||
|
||||
if !io.Exists(logPath) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(logPath, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned += info.Size()
|
||||
// 名称带日期的日志文件,删除旧文件
|
||||
if re.MatchString(entry.Name()) {
|
||||
_ = os.Remove(filePath)
|
||||
} else {
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// cleanWebsiteLogs 清理网站日志
|
||||
func (s *ToolboxLogService) cleanWebsiteLogs() (int64, error) {
|
||||
var cleaned int64
|
||||
sitesPath := filepath.Join(app.Root, "sites")
|
||||
|
||||
if !io.Exists(sitesPath) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
websites := make([]*biz.Website, 0)
|
||||
if err := s.db.Find(&websites).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, website := range websites {
|
||||
logPath := filepath.Join(sitesPath, website.Name, "log")
|
||||
if !io.Exists(logPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(logPath, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned += info.Size()
|
||||
if _, err = shell.Execf("cat /dev/null > '%s'", filePath); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// cleanMySQLLogs 清理 MySQL 日志
|
||||
func (s *ToolboxLogService) cleanMySQLLogs() (int64, error) {
|
||||
var cleaned int64
|
||||
mysqlPath := filepath.Join(app.Root, "server/mysql")
|
||||
|
||||
if !io.Exists(mysqlPath) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 清空慢查询日志
|
||||
slowLogPath := filepath.Join(mysqlPath, "mysql-slow.log")
|
||||
if io.Exists(slowLogPath) {
|
||||
if info, err := os.Stat(slowLogPath); err == nil {
|
||||
cleaned += info.Size()
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", slowLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理二进制日志
|
||||
entries, err := os.ReadDir(mysqlPath)
|
||||
if err != nil {
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
binLogRegex := regexp.MustCompile(`^mysql-bin\.\d+$`)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if binLogRegex.MatchString(entry.Name()) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned += info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试通过 MySQL 清理二进制日志
|
||||
// 从面板设置获取 root 密码
|
||||
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
|
||||
if err == nil && rootPassword != "" {
|
||||
// 设置环境变量
|
||||
if err = os.Setenv("MYSQL_PWD", rootPassword); err == nil {
|
||||
_, _ = shell.Execf("mysql -u root -e 'PURGE BINARY LOGS BEFORE NOW()' 2>/dev/null")
|
||||
_ = os.Unsetenv("MYSQL_PWD")
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// cleanDockerLogs 清理 Docker/Podman 相关内容
|
||||
func (s *ToolboxLogService) cleanDockerLogs() (int64, error) {
|
||||
var cleaned int64
|
||||
|
||||
// 清理未使用的镜像 (Docker)
|
||||
_ = s.containerImageRepo.Prune()
|
||||
|
||||
// 清理 Docker 容器日志
|
||||
dockerLogPath := "/var/lib/docker/containers"
|
||||
cleaned += s.cleanContainerLogDir(dockerLogPath)
|
||||
|
||||
// 清理 Podman 容器日志
|
||||
podmanLogPaths := []string{
|
||||
"/var/lib/containers/storage/overlay-containers",
|
||||
"/run/containers/storage/overlay-containers",
|
||||
}
|
||||
for _, podmanLogPath := range podmanLogPaths {
|
||||
cleaned += s.cleanContainerLogDir(podmanLogPath)
|
||||
}
|
||||
|
||||
// 清理 Docker 系统
|
||||
_, _ = shell.Execf("docker system prune -f 2>/dev/null")
|
||||
|
||||
// 清理 Podman 系统
|
||||
_, _ = shell.Execf("podman system prune -f 2>/dev/null")
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// cleanContainerLogDir 清理容器日志目录
|
||||
func (s *ToolboxLogService) cleanContainerLogDir(logPath string) int64 {
|
||||
var cleaned int64
|
||||
|
||||
if !io.Exists(logPath) {
|
||||
return 0
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
containerPath := filepath.Join(logPath, entry.Name())
|
||||
|
||||
// 清理 *.log 文件
|
||||
logFiles, _ := filepath.Glob(filepath.Join(containerPath, "*.log"))
|
||||
for _, logFile := range logFiles {
|
||||
if info, err := os.Stat(logFile); err == nil {
|
||||
cleaned += info.Size()
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", logFile)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 userdata 子目录下的日志 (Podman)
|
||||
userdataPath := filepath.Join(containerPath, "userdata")
|
||||
if io.Exists(userdataPath) {
|
||||
userdataLogs, _ := filepath.Glob(filepath.Join(userdataPath, "*.log"))
|
||||
for _, logFile := range userdataLogs {
|
||||
if info, err := os.Stat(logFile); err == nil {
|
||||
cleaned += info.Size()
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// cleanSystemLogs 清理系统日志
|
||||
func (s *ToolboxLogService) cleanSystemLogs() (int64, error) {
|
||||
var cleaned int64
|
||||
|
||||
// 清理 journal 日志 (保留最近 1 天)
|
||||
_, _ = shell.Execf("journalctl --vacuum-time=1d 2>/dev/null")
|
||||
|
||||
logFiles := []string{
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dmesg",
|
||||
"/var/log/btmp",
|
||||
"/var/log/wtmp",
|
||||
}
|
||||
|
||||
for _, logFile := range logFiles {
|
||||
if !io.Exists(logFile) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(logFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned += info.Size()
|
||||
// 清空日志文件
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", logFile)
|
||||
}
|
||||
|
||||
// 清理 /var/log/*.log 文件
|
||||
matches, _ := filepath.Glob("/var/log/*.log")
|
||||
for _, match := range matches {
|
||||
if lo.Contains(logFiles, match) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cleaned += info.Size()
|
||||
_, _ = shell.Execf("cat /dev/null > '%s'", match)
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
8
web/src/api/panel/toolbox-log/index.ts
Normal file
8
web/src/api/panel/toolbox-log/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { http } from '@/utils'
|
||||
|
||||
export default {
|
||||
// 扫描日志
|
||||
scan: (type: string): any => http.Get('/toolbox_log/scan', { params: { type } }),
|
||||
// 清理日志
|
||||
clean: (type: string): any => http.Post('/toolbox_log/clean', { type })
|
||||
}
|
||||
@@ -5,6 +5,7 @@ defineOptions({
|
||||
|
||||
import BenchmarkView from '@/views/toolbox/BenchmarkView.vue'
|
||||
import DiskView from '@/views/toolbox/DiskView.vue'
|
||||
import LogView from '@/views/toolbox/LogView.vue'
|
||||
import ProcessView from '@/views/toolbox/ProcessView.vue'
|
||||
import SshView from '@/views/toolbox/SshView.vue'
|
||||
import SystemView from '@/views/toolbox/SystemView.vue'
|
||||
@@ -23,6 +24,7 @@ const current = ref('process')
|
||||
<n-tab name="system" :tab="$gettext('System')" />
|
||||
<n-tab name="ssh" tab="SSH" />
|
||||
<n-tab name="disk" :tab="$gettext('Disk')" />
|
||||
<n-tab name="log" :tab="$gettext('Log Clean')" />
|
||||
<n-tab name="webhook" :tab="$gettext('WebHook')" />
|
||||
<n-tab name="benchmark" :tab="$gettext('Benchmark')" />
|
||||
</n-tabs>
|
||||
@@ -32,6 +34,7 @@ const current = ref('process')
|
||||
<system-view v-if="current === 'system'" />
|
||||
<ssh-view v-if="current === 'ssh'" />
|
||||
<disk-view v-if="current === 'disk'" />
|
||||
<log-view v-if="current === 'log'" />
|
||||
<web-hook-view v-if="current === 'webhook'" />
|
||||
<benchmark-view v-if="current === 'benchmark'" />
|
||||
</n-flex>
|
||||
|
||||
227
web/src/views/toolbox/LogView.vue
Normal file
227
web/src/views/toolbox/LogView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'toolbox-log'
|
||||
})
|
||||
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import toolboxLog from '@/api/panel/toolbox-log'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
// 日志类型
|
||||
interface LogType {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 日志项
|
||||
interface LogItem {
|
||||
name: string
|
||||
path: string
|
||||
size: string
|
||||
}
|
||||
|
||||
// 扫描结果
|
||||
interface ScanResult {
|
||||
loading: boolean
|
||||
items: LogItem[]
|
||||
scanned: boolean
|
||||
cleaning: boolean
|
||||
}
|
||||
|
||||
const logTypes: LogType[] = [
|
||||
{
|
||||
key: 'panel',
|
||||
name: $gettext('Panel Logs'),
|
||||
description: $gettext('Panel runtime logs'),
|
||||
icon: 'mdi:view-dashboard-outline'
|
||||
},
|
||||
{
|
||||
key: 'website',
|
||||
name: $gettext('Website Logs'),
|
||||
description: $gettext('Website access and error logs'),
|
||||
icon: 'mdi:web'
|
||||
},
|
||||
{
|
||||
key: 'mysql',
|
||||
name: $gettext('MySQL Logs'),
|
||||
description: $gettext('MySQL slow query logs and binary logs'),
|
||||
icon: 'mdi:database'
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
name: $gettext('Docker'),
|
||||
description: $gettext('Docker container logs and unused images'),
|
||||
icon: 'mdi:docker'
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
name: $gettext('System Logs'),
|
||||
description: $gettext('System logs and journal logs'),
|
||||
icon: 'mdi:server'
|
||||
}
|
||||
]
|
||||
|
||||
// 扫描结果状态
|
||||
const scanResults = ref<Record<string, ScanResult>>({
|
||||
panel: { loading: false, items: [], scanned: false, cleaning: false },
|
||||
website: { loading: false, items: [], scanned: false, cleaning: false },
|
||||
mysql: { loading: false, items: [], scanned: false, cleaning: false },
|
||||
docker: { loading: false, items: [], scanned: false, cleaning: false },
|
||||
system: { loading: false, items: [], scanned: false, cleaning: false }
|
||||
})
|
||||
|
||||
// 扫描日志
|
||||
const handleScan = async (type: string) => {
|
||||
scanResults.value[type].loading = true
|
||||
scanResults.value[type].scanned = false
|
||||
scanResults.value[type].items = []
|
||||
|
||||
try {
|
||||
const { data } = await useRequest(toolboxLog.scan(type))
|
||||
scanResults.value[type].items = data || []
|
||||
scanResults.value[type].scanned = true
|
||||
} catch (e) {
|
||||
window.$message.error($gettext('Scan failed'))
|
||||
} finally {
|
||||
scanResults.value[type].loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理日志
|
||||
const handleClean = async (type: string) => {
|
||||
scanResults.value[type].cleaning = true
|
||||
|
||||
try {
|
||||
const { data } = await useRequest(toolboxLog.clean(type))
|
||||
window.$message.success($gettext('Cleaned: %{ size }', { size: data.cleaned }))
|
||||
// 重新扫描
|
||||
await handleScan(type)
|
||||
} catch (e) {
|
||||
window.$message.error($gettext('Clean failed'))
|
||||
} finally {
|
||||
scanResults.value[type].cleaning = false
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描所有
|
||||
const handleScanAll = async () => {
|
||||
for (const logType of logTypes) {
|
||||
await handleScan(logType.key)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有
|
||||
const handleCleanAll = async () => {
|
||||
for (const logType of logTypes) {
|
||||
if (scanResults.value[logType.key].items.length > 0) {
|
||||
await handleClean(logType.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
const totalItems = computed(() => {
|
||||
return Object.values(scanResults.value).reduce((acc, cur) => acc + cur.items.length, 0)
|
||||
})
|
||||
|
||||
// 计算是否有任何正在加载
|
||||
const anyLoading = computed(() => {
|
||||
return Object.values(scanResults.value).some((r) => r.loading || r.cleaning)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="anyLoading" @click="handleScanAll">
|
||||
<template #icon>
|
||||
<i-mdi-magnify />
|
||||
</template>
|
||||
{{ $gettext('Scan All') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
type="warning"
|
||||
:loading="anyLoading"
|
||||
:disabled="totalItems === 0"
|
||||
@click="handleCleanAll"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-delete-sweep />
|
||||
</template>
|
||||
{{ $gettext('Clean All') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-grid :cols="1" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="logType in logTypes" :key="logType.key">
|
||||
<n-card :title="logType.name">
|
||||
<template #header-extra>
|
||||
<n-flex :size="8">
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="scanResults[logType.key].loading"
|
||||
@click="handleScan(logType.key)"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-magnify />
|
||||
</template>
|
||||
{{ $gettext('Scan') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="warning"
|
||||
:loading="scanResults[logType.key].cleaning"
|
||||
:disabled="scanResults[logType.key].items.length === 0"
|
||||
@click="handleClean(logType.key)"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-delete />
|
||||
</template>
|
||||
{{ $gettext('Clean') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<n-flex vertical>
|
||||
<n-text depth="3">{{ logType.description }}</n-text>
|
||||
|
||||
<template v-if="scanResults[logType.key].loading">
|
||||
<n-flex justify="center" align="center" style="min-height: 60px">
|
||||
<n-spin size="small" />
|
||||
<n-text>{{ $gettext('Scanning...') }}</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<template v-else-if="scanResults[logType.key].scanned">
|
||||
<template v-if="scanResults[logType.key].items.length === 0">
|
||||
<n-empty :description="$gettext('No logs found')" size="small" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{ title: $gettext('Name'), key: 'name', ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Size'), key: 'size', width: 120 }
|
||||
]"
|
||||
:data="scanResults[logType.key].items"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="200"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<n-flex justify="center" align="center" style="min-height: 60px">
|
||||
<n-text depth="3">{{ $gettext('Click Scan to check logs') }}</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-flex>
|
||||
</template>
|
||||
Reference in New Issue
Block a user