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 @@ + + + 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() +}