From 72cb2689e8844ddca723edceb5c6d8590c8aa3b4 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:55:58 +0800 Subject: [PATCH 1/2] feat(management): add log retrieval and cleanup endpoints --- internal/api/handlers/management/handler.go | 15 + internal/api/handlers/management/logs.go | 344 ++++++++++++++++++++ internal/api/server.go | 3 + 3 files changed, 362 insertions(+) create mode 100644 internal/api/handlers/management/logs.go diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 9497e39b..5371f9fd 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -37,6 +38,7 @@ type Handler struct { localPassword string allowRemoteOverride bool envSecret string + logDir string } // NewHandler creates a new management handler instance. @@ -68,6 +70,19 @@ func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageSt // SetLocalPassword configures the runtime-local password accepted for localhost requests. func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } +// SetLogDirectory updates the directory where main.log should be looked up. +func (h *Handler) SetLogDirectory(dir string) { + if dir == "" { + return + } + if !filepath.IsAbs(dir) { + if abs, err := filepath.Abs(dir); err == nil { + dir = abs + } + } + h.logDir = dir +} + // Middleware enforces access control for management endpoints. // All requests (local and remote) require a valid management key. // Additionally, remote access requires allow-remote-management=true. diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go new file mode 100644 index 00000000..e92a7a80 --- /dev/null +++ b/internal/api/handlers/management/logs.go @@ -0,0 +1,344 @@ +package management + +import ( + "bufio" + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + defaultLogFileName = "main.log" + logScannerInitialBuffer = 64 * 1024 + logScannerMaxBuffer = 8 * 1024 * 1024 +) + +// GetLogs returns log lines with optional incremental loading. +func (h *Handler) GetLogs(c *gin.Context) { + if h == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) + return + } + if h.cfg == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) + return + } + if !h.cfg.LoggingToFile { + c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"}) + return + } + + logDir := h.logDirectory() + if strings.TrimSpace(logDir) == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) + return + } + + files, err := h.collectLogFiles(logDir) + if err != nil { + if os.IsNotExist(err) { + cutoff := parseCutoff(c.Query("after")) + c.JSON(http.StatusOK, gin.H{ + "lines": []string{}, + "line-count": 0, + "latest-timestamp": cutoff, + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)}) + return + } + + cutoff := parseCutoff(c.Query("after")) + acc := newLogAccumulator(cutoff) + for i := range files { + if errProcess := acc.consumeFile(files[i]); errProcess != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)}) + return + } + } + + lines, total, latest := acc.result() + if latest == 0 || latest < cutoff { + latest = cutoff + } + c.JSON(http.StatusOK, gin.H{ + "lines": lines, + "line-count": total, + "latest-timestamp": latest, + }) +} + +// DeleteLogs removes all rotated log files and truncates the active log. +func (h *Handler) DeleteLogs(c *gin.Context) { + if h == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) + return + } + if h.cfg == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) + return + } + if !h.cfg.LoggingToFile { + c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"}) + return + } + + dir := h.logDirectory() + if strings.TrimSpace(dir) == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)}) + return + } + + removed := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + fullPath := filepath.Join(dir, name) + if name == defaultLogFileName { + if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)}) + return + } + continue + } + if isRotatedLogFile(name) { + if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)}) + return + } + removed++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Logs cleared successfully", + "removed": removed, + }) +} + +func (h *Handler) logDirectory() string { + if h == nil { + return "" + } + if h.logDir != "" { + return h.logDir + } + if h.configFilePath != "" { + dir := filepath.Dir(h.configFilePath) + if dir != "" && dir != "." { + return filepath.Join(dir, "logs") + } + } + return "logs" +} + +func (h *Handler) collectLogFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + type candidate struct { + path string + order int64 + } + cands := make([]candidate, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if name == defaultLogFileName { + cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0}) + continue + } + if order, ok := rotationOrder(name); ok { + cands = append(cands, candidate{path: filepath.Join(dir, name), order: order}) + } + } + if len(cands) == 0 { + return []string{}, nil + } + sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order }) + paths := make([]string, 0, len(cands)) + for i := len(cands) - 1; i >= 0; i-- { + paths = append(paths, cands[i].path) + } + return paths, nil +} + +type logAccumulator struct { + cutoff int64 + lines []string + total int + latest int64 + include bool +} + +func newLogAccumulator(cutoff int64) *logAccumulator { + return &logAccumulator{ + cutoff: cutoff, + lines: make([]string, 0, 256), + } +} + +func (acc *logAccumulator) consumeFile(path string) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, logScannerInitialBuffer) + scanner.Buffer(buf, logScannerMaxBuffer) + for scanner.Scan() { + acc.addLine(scanner.Text()) + } + if errScan := scanner.Err(); errScan != nil { + return errScan + } + return nil +} + +func (acc *logAccumulator) addLine(raw string) { + line := strings.TrimRight(raw, "\r") + acc.total++ + ts := parseTimestamp(line) + if ts > acc.latest { + acc.latest = ts + } + if ts > 0 { + acc.include = acc.cutoff == 0 || ts > acc.cutoff + if acc.cutoff == 0 || acc.include { + acc.lines = append(acc.lines, line) + } + return + } + if acc.cutoff == 0 || acc.include { + acc.lines = append(acc.lines, line) + } +} + +func (acc *logAccumulator) result() ([]string, int, int64) { + if acc.lines == nil { + acc.lines = []string{} + } + return acc.lines, acc.total, acc.latest +} + +func parseCutoff(raw string) int64 { + value := strings.TrimSpace(raw) + if value == "" { + return 0 + } + ts, err := strconv.ParseInt(value, 10, 64) + if err != nil || ts <= 0 { + return 0 + } + return ts +} + +func parseTimestamp(line string) int64 { + if strings.HasPrefix(line, "[") { + line = line[1:] + } + if len(line) < 19 { + return 0 + } + candidate := line[:19] + t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local) + if err != nil { + return 0 + } + return t.Unix() +} + +func isRotatedLogFile(name string) bool { + if _, ok := rotationOrder(name); ok { + return true + } + return false +} + +func rotationOrder(name string) (int64, bool) { + if order, ok := numericRotationOrder(name); ok { + return order, true + } + if order, ok := timestampRotationOrder(name); ok { + return order, true + } + return 0, false +} + +func numericRotationOrder(name string) (int64, bool) { + if !strings.HasPrefix(name, defaultLogFileName+".") { + return 0, false + } + suffix := strings.TrimPrefix(name, defaultLogFileName+".") + if suffix == "" { + return 0, false + } + n, err := strconv.Atoi(suffix) + if err != nil { + return 0, false + } + return int64(n), true +} + +func timestampRotationOrder(name string) (int64, bool) { + ext := filepath.Ext(defaultLogFileName) + base := strings.TrimSuffix(defaultLogFileName, ext) + if base == "" { + return 0, false + } + prefix := base + "-" + if !strings.HasPrefix(name, prefix) { + return 0, false + } + clean := strings.TrimPrefix(name, prefix) + if strings.HasSuffix(clean, ".gz") { + clean = strings.TrimSuffix(clean, ".gz") + } + if ext != "" { + if !strings.HasSuffix(clean, ext) { + return 0, false + } + clean = strings.TrimSuffix(clean, ext) + } + if clean == "" { + return 0, false + } + if idx := strings.IndexByte(clean, '.'); idx != -1 { + clean = clean[:idx] + } + parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local) + if err != nil { + return 0, false + } + return math.MaxInt64 - parsed.Unix(), true +} diff --git a/internal/api/server.go b/internal/api/server.go index c2e0e3c3..4a99316f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -233,6 +233,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk if optionState.localPassword != "" { s.mgmt.SetLocalPassword(optionState.localPassword) } + s.mgmt.SetLogDirectory(filepath.Join(s.currentPath, "logs")) s.localPassword = optionState.localPassword // Setup routes @@ -411,6 +412,8 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys) mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys) + mgmt.GET("/logs", s.mgmt.GetLogs) + mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.GET("/request-log", s.mgmt.GetRequestLog) mgmt.PUT("/request-log", s.mgmt.PutRequestLog) mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) From df3b00621aefa05f7fe28edf4ddc63349f93885e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:35:29 +0800 Subject: [PATCH 2/2] fix(logs): ignore ENOENT when truncating default log file --- internal/api/handlers/management/logs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index e92a7a80..9f7f904f 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -116,7 +116,7 @@ func (h *Handler) DeleteLogs(c *gin.Context) { name := entry.Name() fullPath := filepath.Join(dir, name) if name == defaultLogFileName { - if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil { + if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)}) return }