Files
panel/internal/data/log.go
T
Copilot 4f19a58bba feat: add date selector for viewing archived panel logs (#1316)
* Initial plan

* feat: add date selector for log viewing

- Add date parameter to log list API
- Add log dates listing API
- Update frontend with date selector in all log views
- Add translations for Date field

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: cache regex patterns to avoid recompilation overhead

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: use predefined regex constants instead of sync.Map

Simplified the regex caching by using predefined package-level constants
for the three log types (app, db, http) instead of sync.Map.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* 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>
2026-02-03 00:04:43 +08:00

256 lines
5.4 KiB
Go

package data
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/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$`)
)
type logRepo struct {
db *gorm.DB
}
func NewLogRepo(db *gorm.DB) biz.LogRepo {
return &logRepo{
db: db,
}
}
// 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)
entries, err := os.ReadDir(logDir)
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
}
}
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:]
// 倒序处理,最新的在前面
entries := make([]biz.LogEntry, 0, len(lines))
for i := len(lines) - 1; i >= 0; i-- {
entry, err := r.parseLine(lines[i], logType)
if err != nil {
continue
}
entries = append(entries, entry)
}
// 如果是app日志,查询用户名
if logType == biz.LogTypeApp {
r.fillOperatorNames(entries)
}
return entries, nil
}
// ListDates 获取可用的日志日期列表
func (r *logRepo) ListDates(logType string) ([]string, error) {
logDir := filepath.Join(app.Root, "panel/storage/logs")
entries, err := os.ReadDir(logDir)
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])
}
}
// 按日期倒序排列,最新的在前面
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
return dates, nil
}
// fillOperatorNames 填充操作员用户名
func (r *logRepo) fillOperatorNames(entries []biz.LogEntry) {
// 收集所有用户ID
userIDs := make(map[uint]bool)
for _, entry := range entries {
if entry.OperatorID > 0 {
userIDs[entry.OperatorID] = true
}
}
if len(userIDs) == 0 {
return
}
// 批量查询用户名
ids := make([]uint, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []biz.User
r.db.Select("id", "username").Where("id IN ?", ids).Find(&users)
// 构建ID到用户名的映射
userMap := make(map[uint]string)
for _, user := range users {
userMap[user.ID] = user.Username
}
// 填充用户名
for i := range entries {
if entries[i].OperatorID > 0 {
if username, ok := userMap[entries[i].OperatorID]; ok {
entries[i].OperatorName = username
}
}
}
}
// parseLine 解析日志行
func (r *logRepo) parseLine(line string, logType string) (biz.LogEntry, error) {
var rawEntry map[string]any
if err := json.Unmarshal([]byte(line), &rawEntry); err != nil {
return biz.LogEntry{}, err
}
entry := biz.LogEntry{
Extra: make(map[string]any),
}
// 解析通用字段
if t, ok := rawEntry["time"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, t); err == nil {
entry.Time = parsed
}
}
if level, ok := rawEntry["level"].(string); ok {
entry.Level = level
}
if msg, ok := rawEntry["msg"].(string); ok {
entry.Msg = msg
}
// 解析操作日志特有字段
if logType == biz.LogTypeApp {
if t, ok := rawEntry["type"].(string); ok {
entry.Type = t
}
if opID, ok := rawEntry["operator_id"].(float64); ok {
entry.OperatorID = uint(opID)
}
}
// 其他字段放入Extra
excludeKeys := map[string]bool{
"time": true, "level": true, "msg": true, "type": true, "operator_id": true,
}
for k, v := range rawEntry {
if !excludeKeys[k] {
entry.Extra[k] = v
}
}
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
}
}