feat(management): add log retrieval and cleanup endpoints

This commit is contained in:
hkfires
2025-10-16 11:55:58 +08:00
parent eb2549a782
commit 72cb2689e8
3 changed files with 362 additions and 0 deletions

View File

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

View File

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

View File

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