2
0
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:
Copilot
2026-01-12 21:48:13 +08:00
committed by GitHub
parent e9b299f874
commit 1e5181c88e
9 changed files with 914 additions and 1 deletions

View File

@@ -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
View File

@@ -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=

View 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"`
}

View File

@@ -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)

View File

@@ -40,5 +40,6 @@ var ProviderSet = wire.NewSet(
NewToolboxBenchmarkService,
NewToolboxSSHService,
NewToolboxDiskService,
NewToolboxLogService,
NewWsService,
)

View 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
}

View 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 })
}

View File

@@ -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>

View 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>