diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go
index 35871b357..edd7af98c 100644
--- a/cmd/ace/wire_gen.go
+++ b/cmd/ace/wire_gen.go
@@ -372,7 +372,7 @@ func initAce() (*app.Ace, func() error, error) {
wireValue110_0 := data.NewLogRepo(wireValue4_0)
- wireValue71_0 := biz.NewLogUsecase(wireValue110_0)
+ wireValue71_0 := biz.NewLogUsecase(wireValue110_0, wireValue3_0)
wireValue161_0 := service.NewLogService(wireValue71_0, wireValue1_0)
diff --git a/internal/biz/log.go b/internal/biz/log.go
index b33915fb1..0956eccd5 100644
--- a/internal/biz/log.go
+++ b/internal/biz/log.go
@@ -1,6 +1,8 @@
package biz
import (
+ "context"
+ "log/slog"
"time"
)
@@ -51,14 +53,17 @@ type LogRepo interface {
List(logType string, limit int, date string) ([]LogEntry, error)
// ListDates 获取可用的日志日期列表
ListDates(logType string) ([]string, error)
+ // Clean 清理指定日期及之前的日志
+ Clean(logType string, date string) error
}
type LogUsecase struct {
repo LogRepo
+ log *slog.Logger
}
-func NewLogUsecase(repo LogRepo) *LogUsecase {
- return &LogUsecase{repo: repo}
+func NewLogUsecase(repo LogRepo, log *slog.Logger) *LogUsecase {
+ return &LogUsecase{repo: repo, log: log}
}
func (uc *LogUsecase) List(logType string, limit int, date string) ([]LogEntry, error) {
@@ -68,3 +73,14 @@ func (uc *LogUsecase) List(logType string, limit int, date string) ([]LogEntry,
func (uc *LogUsecase) ListDates(logType string) ([]string, error) {
return uc.repo.ListDates(logType)
}
+
+func (uc *LogUsecase) Clean(ctx context.Context, logType, date string) error {
+ if err := uc.repo.Clean(logType, date); err != nil {
+ return err
+ }
+
+ // 记录日志,操作日志被清空后留下这一条作为痕迹
+ uc.log.Info("logs cleaned", slog.String("type", OperationTypePanel), slog.Uint64("operator_id", operatorID(ctx)), slog.String("log_type", logType), slog.String("date", date))
+
+ return nil
+}
diff --git a/internal/data/log.go b/internal/data/log.go
index 9a0d6e8f7..3f780f7d4 100644
--- a/internal/data/log.go
+++ b/internal/data/log.go
@@ -17,11 +17,15 @@ import (
"github.com/acepanel/panel/v3/internal/biz"
)
-var (
- logArchivePatternApp = regexp.MustCompile(`^app-(\d{4}-\d{2}-\d{2})T.*\.log$`)
- logArchivePatternDB = regexp.MustCompile(`^db-(\d{4}-\d{2}-\d{2})T.*\.log$`)
- logArchivePatternHTTP = regexp.MustCompile(`^http-(\d{4}-\d{2}-\d{2})T.*\.log$`)
-)
+// logArchivePattern 轮转归档的文件名,时间戳是该文件内日志的结束时刻
+var logArchivePattern = regexp.MustCompile(`^(app|db|http)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3})(?:\.\d+)?\.log$`)
+
+// logFile 日志文件及其内日志所属的日期
+type logFile struct {
+ path string
+ date string
+ current bool
+}
type logRepo struct {
db *gorm.DB
@@ -36,75 +40,27 @@ func NewLogRepo(db *gorm.DB) biz.LogRepo {
// List 获取日志列表
// date 格式为 YYYY-MM-DD,空字符串表示当天日志
func (r *logRepo) List(logType string, limit int, date string) ([]biz.LogEntry, error) {
- logDir := filepath.Join(app.Root, "panel/storage/logs")
-
- var logPath string
if date == "" {
- // 无日期参数,读取当前日志文件
- logPath = filepath.Join(logDir, logType+".log")
- } else {
- // 有日期参数,查找对应的归档日志文件
- pattern := getLogArchivePattern(logType)
+ date = time.Now().Format(time.DateOnly)
+ }
+ files, err := r.files(logType)
+ if err != nil {
+ return nil, err
+ }
- entries, err := os.ReadDir(logDir)
+ // 一天可能因为大小轮转分成多个文件,从最新的往前读,凑够 limit 行为止
+ var lines []string
+ for i := len(files) - 1; i >= 0 && len(lines) < limit; i-- {
+ if files[i].date != date {
+ continue
+ }
+ fileLines, err := readLines(files[i].path)
if err != nil {
- if os.IsNotExist(err) {
- return []biz.LogEntry{}, nil
- }
return nil, err
}
-
- found := false
- for _, entry := range entries {
- if entry.IsDir() {
- continue
- }
- matches := pattern.FindStringSubmatch(entry.Name())
- if len(matches) == 2 && matches[1] == date {
- logPath = filepath.Join(logDir, entry.Name())
- found = true
- break
- }
- }
-
- if !found {
- return []biz.LogEntry{}, nil
- }
+ lines = append(fileLines, lines...)
}
-
- file, err := os.Open(logPath)
- if err != nil {
- if os.IsNotExist(err) {
- return []biz.LogEntry{}, nil
- }
- return nil, err
- }
- defer func(file *os.File) { _ = file.Close() }(file)
-
- // 读取所有行
- var lines []string
- scanner := bufio.NewScanner(file)
- // 增加缓冲区大小以处理较长的日志行
- buf := make([]byte, 0, 64*1024)
- scanner.Buffer(buf, 1024*1024)
-
- for scanner.Scan() {
- line := scanner.Text()
- if strings.TrimSpace(line) != "" {
- lines = append(lines, line)
- }
- }
-
- if err = scanner.Err(); err != nil {
- return nil, err
- }
-
- // 从末尾取指定数量的行
- start := 0
- if len(lines) > limit {
- start = len(lines) - limit
- }
- lines = lines[start:]
+ lines = lines[max(0, len(lines)-limit):]
// 倒序处理,最新的在前面
entries := make([]biz.LogEntry, 0, len(lines))
@@ -124,30 +80,17 @@ func (r *logRepo) List(logType string, limit int, date string) ([]biz.LogEntry,
return entries, nil
}
-// ListDates 获取可用的日志日期列表
+// ListDates 获取可用的日志日期列表,当天由 List 的空日期覆盖,不在其中
func (r *logRepo) ListDates(logType string) ([]string, error) {
- logDir := filepath.Join(app.Root, "panel/storage/logs")
-
- entries, err := os.ReadDir(logDir)
+ files, err := r.files(logType)
if err != nil {
- if os.IsNotExist(err) {
- return []string{}, nil
- }
return nil, err
}
- pattern := getLogArchivePattern(logType)
-
- dates := make([]string, 0)
- for _, entry := range entries {
- if entry.IsDir() {
- continue
- }
- matches := pattern.FindStringSubmatch(entry.Name())
- if len(matches) == 2 {
- dates = append(dates, matches[1])
- }
- }
+ today := time.Now().Format(time.DateOnly)
+ dates := lo.Uniq(lo.FilterMap(files, func(file logFile, _ int) (string, bool) {
+ return file.date, file.date != today
+ }))
// 按日期倒序排列,最新的在前面
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
@@ -155,6 +98,89 @@ func (r *logRepo) ListDates(logType string) ([]string, error) {
return dates, nil
}
+// Clean 清理指定日期及之前的日志,当前文件还在写入,只能清空
+func (r *logRepo) Clean(logType string, date string) error {
+ files, err := r.files(logType)
+ if err != nil {
+ return err
+ }
+
+ for _, file := range files {
+ if file.date > date {
+ continue
+ }
+ if file.current {
+ err = os.Truncate(file.path, 0)
+ } else {
+ err = os.Remove(file.path)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// files 按时间先后列出某类日志的归档与当前文件
+func (r *logRepo) files(logType string) ([]logFile, error) {
+ dir := filepath.Join(app.Root, "panel/storage/logs")
+ entries, err := os.ReadDir(dir)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ files := make([]logFile, 0, len(entries))
+ for _, entry := range entries {
+ matches := logArchivePattern.FindStringSubmatch(entry.Name())
+ if matches == nil || matches[1] != logType {
+ continue
+ }
+ end, err := time.ParseInLocation("2006-01-02T15-04-05.000", matches[2], time.Local)
+ if err != nil {
+ continue
+ }
+ // 零点轮转的归档时间戳是次日零点,往前退一点才是日志所属的日期
+ files = append(files, logFile{
+ path: filepath.Join(dir, entry.Name()),
+ date: end.Add(-time.Nanosecond).Format(time.DateOnly),
+ })
+ }
+
+ // 当前文件名里没有时间戳,按最后写入时间算
+ current := filepath.Join(dir, logType+".log")
+ if info, err := os.Stat(current); err == nil {
+ files = append(files, logFile{path: current, date: info.ModTime().Format(time.DateOnly), current: true})
+ }
+
+ return files, nil
+}
+
+// readLines 读取文件中的非空行
+func readLines(path string) ([]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ // 列出后可能刚被轮转清理掉
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer func(file *os.File) { _ = file.Close() }(file)
+
+ var lines []string
+ scanner := bufio.NewScanner(file)
+ // 增加缓冲区大小以处理较长的日志行
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ if line := scanner.Text(); strings.TrimSpace(line) != "" {
+ lines = append(lines, line)
+ }
+ }
+
+ return lines, scanner.Err()
+}
+
// fillOperatorNames 填充操作员用户名
func (r *logRepo) fillOperatorNames(entries []biz.LogEntry) {
// 收集所有用户ID
@@ -236,17 +262,3 @@ func (r *logRepo) parseLine(line string, logType string) (biz.LogEntry, error) {
return entry, nil
}
-
-// getLogArchivePattern 获取归档日志文件名匹配正则表达式
-func getLogArchivePattern(logType string) *regexp.Regexp {
- switch logType {
- case biz.LogTypeApp:
- return logArchivePatternApp
- case biz.LogTypeDB:
- return logArchivePatternDB
- case biz.LogTypeHTTP:
- return logArchivePatternHTTP
- default:
- return logArchivePatternApp
- }
-}
diff --git a/internal/mocks/biz/LogRepo.go b/internal/mocks/biz/LogRepo.go
index c98181a7f..e7825f961 100644
--- a/internal/mocks/biz/LogRepo.go
+++ b/internal/mocks/biz/LogRepo.go
@@ -20,6 +20,9 @@ var _ biz.LogRepo = &LogRepo{}
//
// // make and configure a mocked biz.LogRepo
// mockedLogRepo := &LogRepo{
+// CleanFunc: func(logType string, date string) error {
+// panic("mock out the Clean method")
+// },
// ListFunc: func(logType string, limit int, date string) ([]biz.LogEntry, error) {
// panic("mock out the List method")
// },
@@ -33,6 +36,9 @@ var _ biz.LogRepo = &LogRepo{}
//
// }
type LogRepo struct {
+ // CleanFunc mocks the Clean method.
+ CleanFunc func(logType string, date string) error
+
// ListFunc mocks the List method.
ListFunc func(logType string, limit int, date string) ([]biz.LogEntry, error)
@@ -41,6 +47,13 @@ type LogRepo struct {
// calls tracks calls to the methods.
calls struct {
+ // Clean holds details about calls to the Clean method.
+ Clean []struct {
+ // LogType is the logType argument value.
+ LogType string
+ // Date is the date argument value.
+ Date string
+ }
// List holds details about calls to the List method.
List []struct {
// LogType is the logType argument value.
@@ -56,10 +69,47 @@ type LogRepo struct {
LogType string
}
}
+ lockClean sync.RWMutex
lockList sync.RWMutex
lockListDates sync.RWMutex
}
+// Clean calls CleanFunc.
+func (mock *LogRepo) Clean(logType string, date string) error {
+ if mock.CleanFunc == nil {
+ panic("LogRepo.CleanFunc: method is nil but LogRepo.Clean was just called")
+ }
+ callInfo := struct {
+ LogType string
+ Date string
+ }{
+ LogType: logType,
+ Date: date,
+ }
+ mock.lockClean.Lock()
+ mock.calls.Clean = append(mock.calls.Clean, callInfo)
+ mock.lockClean.Unlock()
+ return mock.CleanFunc(logType, date)
+}
+
+// CleanCalls gets all the calls that were made to Clean.
+// Check the length with:
+//
+// len(mockedLogRepo.CleanCalls())
+func (mock *LogRepo) CleanCalls() []struct {
+ LogType string
+ Date string
+} {
+ var calls []struct {
+ LogType string
+ Date string
+ }
+ mock.lockClean.RLock()
+ calls = mock.calls.Clean
+ mock.lockClean.RUnlock()
+ return calls
+}
+
// List calls ListFunc.
func (mock *LogRepo) List(logType string, limit int, date string) ([]biz.LogEntry, error) {
if mock.ListFunc == nil {
diff --git a/internal/request/log.go b/internal/request/log.go
index bc5cc692c..4e37a7e1e 100644
--- a/internal/request/log.go
+++ b/internal/request/log.go
@@ -11,3 +11,9 @@ type LogList struct {
type LogDates struct {
Type string `json:"type" form:"type" query:"type" validate:"required && in:app,db,http"`
}
+
+// LogClean 日志清理请求
+type LogClean struct {
+ Type string `json:"type" form:"type" validate:"required && in:app,db,http"`
+ Date string `json:"date" form:"date" validate:"required && datetime:2006-01-02"` // 清理该日期及之前的日志
+}
diff --git a/internal/route/log.go b/internal/route/log.go
index 01e370e09..f203f676e 100644
--- a/internal/route/log.go
+++ b/internal/route/log.go
@@ -14,6 +14,7 @@ func LogRoutes(logService *service.LogService) Endpoints {
return Endpoints{
{Method: http.MethodGet, Path: "/api/log/list", Handler: svc.List, Summary: "日志列表", Tags: []string{"日志"}, Document: DescribeReq[request.LogList]()},
{Method: http.MethodGet, Path: "/api/log/dates", Handler: svc.Dates, Summary: "日志日期列表", Tags: []string{"日志"}, Document: DescribeReq[request.LogDates]()},
+ {Method: http.MethodPost, Path: "/api/log/clean", Handler: svc.Clean, Summary: "清理日志", Tags: []string{"日志"}, Document: DescribeReq[request.LogClean]()},
{Method: http.MethodGet, Path: "/api/log/ssh", Handler: svc.SSH, Summary: "SSH 登录日志", Tags: []string{"日志"}},
}
}
diff --git a/internal/service/log.go b/internal/service/log.go
index 310621f81..751f8cbef 100644
--- a/internal/service/log.go
+++ b/internal/service/log.go
@@ -79,6 +79,22 @@ func (s *LogService) Dates(w http.ResponseWriter, r *http.Request) {
Success(w, dates)
}
+// Clean 清理指定日期及之前的日志
+func (s *LogService) Clean(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.LogClean](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ if err = s.logRepo.Clean(r.Context(), req.Type, req.Date); err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, nil)
+}
+
// SSH 获取 SSH 登录日志
func (s *LogService) SSH(w http.ResponseWriter, r *http.Request) {
limit := 100
diff --git a/web/src/api/panel/log/index.ts b/web/src/api/panel/log/index.ts
index 4e1967368..ae13ac22f 100644
--- a/web/src/api/panel/log/index.ts
+++ b/web/src/api/panel/log/index.ts
@@ -6,6 +6,9 @@ export default {
http.Get('/log/list', { params: { type, limit, date } }),
// 获取日志日期列表
dates: (type: 'app' | 'db' | 'http'): any => http.Get('/log/dates', { params: { type } }),
+ // 清理指定日期及之前的日志
+ clean: (type: 'app' | 'db' | 'http', date: string): any =>
+ http.Post('/log/clean', { type, date }),
// 获取 SSH 登录日志
ssh: (limit: number = 100): any => http.Get('/log/ssh', { params: { limit } }),
}
diff --git a/web/src/views/log/CleanPopover.vue b/web/src/views/log/CleanPopover.vue
new file mode 100644
index 000000000..cba597b30
--- /dev/null
+++ b/web/src/views/log/CleanPopover.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ {{ $gettext('Clean') }}
+
+
+
+
+
+ {{ $gettext('Delete logs on and before the selected date') }}
+
+
+ {{ $gettext('Clean') }}
+
+
+
+
diff --git a/web/src/views/log/DatabaseLog.vue b/web/src/views/log/DatabaseLog.vue
index 2e13d692a..511d510d2 100644
--- a/web/src/views/log/DatabaseLog.vue
+++ b/web/src/views/log/DatabaseLog.vue
@@ -8,6 +8,8 @@ import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
+import CleanPopover from './CleanPopover.vue'
+
const { $gettext } = useGettext()
// 日志条目类型定义
@@ -20,10 +22,12 @@ interface LogEntry {
// 数据加载
const limit = ref(200)
-const selectedDate = ref(null)
+const selectedDate = ref('')
// 获取可用的日志日期列表
-const { data: dates } = useRequest(() => log.dates('db'), { initialData: [] })
+const { data: dates, send: refreshDates } = useRequest(() => log.dates('db'), {
+ initialData: [],
+})
// 日期选项
const dateOptions = computed(() => {
@@ -40,7 +44,7 @@ const {
loading,
data,
send: refresh,
-} = useRequest(() => log.list('db', limit.value, selectedDate.value || ''), { initialData: [] })
+} = useRequest(() => log.list('db', limit.value, selectedDate.value), { initialData: [] })
// 表格列配置
const columns = [
@@ -104,6 +108,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
+
+// 清理后选中的日期可能已被删掉,回到今天
+const handleCleaned = () => {
+ selectedDate.value = ''
+ refreshDates()
+ refresh()
+}
@@ -131,6 +142,7 @@ const handleRefresh = () => {
{{ $gettext('Refresh') }}
+
(null)
+const selectedDate = ref('')
// 获取可用的日志日期列表
-const { data: dates } = useRequest(() => log.dates('http'), { initialData: [] })
+const { data: dates, send: refreshDates } = useRequest(() => log.dates('http'), {
+ initialData: [],
+})
// 日期选项
const dateOptions = computed(() => {
@@ -40,7 +44,7 @@ const {
loading,
data,
send: refresh,
-} = useRequest(() => log.list('http', limit.value, selectedDate.value || ''), { initialData: [] })
+} = useRequest(() => log.list('http', limit.value, selectedDate.value), { initialData: [] })
// 获取状态码颜色
const getStatusType = (code: number): 'success' | 'warning' | 'error' | 'info' => {
@@ -133,6 +137,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
+
+// 清理后选中的日期可能已被删掉,回到今天
+const handleCleaned = () => {
+ selectedDate.value = ''
+ refreshDates()
+ refresh()
+}
@@ -160,6 +171,7 @@ const handleRefresh = () => {
{{ $gettext('Refresh') }}
+
(null)
+const selectedDate = ref('')
// 获取可用的日志日期列表
-const { data: dates } = useRequest(() => log.dates('app'), { initialData: [] })
+const { data: dates, send: refreshDates } = useRequest(() => log.dates('app'), {
+ initialData: [],
+})
// 日期选项
const dateOptions = computed(() => {
@@ -43,7 +47,7 @@ const {
loading,
data,
send: refresh,
-} = useRequest(() => log.list('app', limit.value, selectedDate.value || ''), { initialData: [] })
+} = useRequest(() => log.list('app', limit.value, selectedDate.value), { initialData: [] })
// 表格列配置
const columns = [
@@ -156,6 +160,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
+
+// 清理后选中的日期可能已被删掉,回到今天
+const handleCleaned = () => {
+ selectedDate.value = ''
+ refreshDates()
+ refresh()
+}
@@ -183,6 +194,7 @@ const handleRefresh = () => {
{{ $gettext('Refresh') }}
+