Skip to content

Commit f58d0fa

Browse files
authored
Merge pull request router-for-me#130 from router-for-me/log
feat(management): add log retrieval and cleanup endpoints
2 parents ade279d + df3b006 commit f58d0fa

File tree

3 files changed

+362
-0
lines changed

3 files changed

+362
-0
lines changed

internal/api/handlers/management/handler.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/http"
99
"os"
10+
"path/filepath"
1011
"strings"
1112
"sync"
1213
"time"
@@ -37,6 +38,7 @@ type Handler struct {
3738
localPassword string
3839
allowRemoteOverride bool
3940
envSecret string
41+
logDir string
4042
}
4143

4244
// NewHandler creates a new management handler instance.
@@ -68,6 +70,19 @@ func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageSt
6870
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
6971
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
7072

73+
// SetLogDirectory updates the directory where main.log should be looked up.
74+
func (h *Handler) SetLogDirectory(dir string) {
75+
if dir == "" {
76+
return
77+
}
78+
if !filepath.IsAbs(dir) {
79+
if abs, err := filepath.Abs(dir); err == nil {
80+
dir = abs
81+
}
82+
}
83+
h.logDir = dir
84+
}
85+
7186
// Middleware enforces access control for management endpoints.
7287
// All requests (local and remote) require a valid management key.
7388
// Additionally, remote access requires allow-remote-management=true.
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
package management
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"math"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"sort"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
"github.com/gin-gonic/gin"
16+
)
17+
18+
const (
19+
defaultLogFileName = "main.log"
20+
logScannerInitialBuffer = 64 * 1024
21+
logScannerMaxBuffer = 8 * 1024 * 1024
22+
)
23+
24+
// GetLogs returns log lines with optional incremental loading.
25+
func (h *Handler) GetLogs(c *gin.Context) {
26+
if h == nil {
27+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
28+
return
29+
}
30+
if h.cfg == nil {
31+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
32+
return
33+
}
34+
if !h.cfg.LoggingToFile {
35+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
36+
return
37+
}
38+
39+
logDir := h.logDirectory()
40+
if strings.TrimSpace(logDir) == "" {
41+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
42+
return
43+
}
44+
45+
files, err := h.collectLogFiles(logDir)
46+
if err != nil {
47+
if os.IsNotExist(err) {
48+
cutoff := parseCutoff(c.Query("after"))
49+
c.JSON(http.StatusOK, gin.H{
50+
"lines": []string{},
51+
"line-count": 0,
52+
"latest-timestamp": cutoff,
53+
})
54+
return
55+
}
56+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
57+
return
58+
}
59+
60+
cutoff := parseCutoff(c.Query("after"))
61+
acc := newLogAccumulator(cutoff)
62+
for i := range files {
63+
if errProcess := acc.consumeFile(files[i]); errProcess != nil {
64+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
65+
return
66+
}
67+
}
68+
69+
lines, total, latest := acc.result()
70+
if latest == 0 || latest < cutoff {
71+
latest = cutoff
72+
}
73+
c.JSON(http.StatusOK, gin.H{
74+
"lines": lines,
75+
"line-count": total,
76+
"latest-timestamp": latest,
77+
})
78+
}
79+
80+
// DeleteLogs removes all rotated log files and truncates the active log.
81+
func (h *Handler) DeleteLogs(c *gin.Context) {
82+
if h == nil {
83+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
84+
return
85+
}
86+
if h.cfg == nil {
87+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
88+
return
89+
}
90+
if !h.cfg.LoggingToFile {
91+
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
92+
return
93+
}
94+
95+
dir := h.logDirectory()
96+
if strings.TrimSpace(dir) == "" {
97+
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
98+
return
99+
}
100+
101+
entries, err := os.ReadDir(dir)
102+
if err != nil {
103+
if os.IsNotExist(err) {
104+
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
105+
return
106+
}
107+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
108+
return
109+
}
110+
111+
removed := 0
112+
for _, entry := range entries {
113+
if entry.IsDir() {
114+
continue
115+
}
116+
name := entry.Name()
117+
fullPath := filepath.Join(dir, name)
118+
if name == defaultLogFileName {
119+
if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
120+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
121+
return
122+
}
123+
continue
124+
}
125+
if isRotatedLogFile(name) {
126+
if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
127+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
128+
return
129+
}
130+
removed++
131+
}
132+
}
133+
134+
c.JSON(http.StatusOK, gin.H{
135+
"success": true,
136+
"message": "Logs cleared successfully",
137+
"removed": removed,
138+
})
139+
}
140+
141+
func (h *Handler) logDirectory() string {
142+
if h == nil {
143+
return ""
144+
}
145+
if h.logDir != "" {
146+
return h.logDir
147+
}
148+
if h.configFilePath != "" {
149+
dir := filepath.Dir(h.configFilePath)
150+
if dir != "" && dir != "." {
151+
return filepath.Join(dir, "logs")
152+
}
153+
}
154+
return "logs"
155+
}
156+
157+
func (h *Handler) collectLogFiles(dir string) ([]string, error) {
158+
entries, err := os.ReadDir(dir)
159+
if err != nil {
160+
return nil, err
161+
}
162+
type candidate struct {
163+
path string
164+
order int64
165+
}
166+
cands := make([]candidate, 0, len(entries))
167+
for _, entry := range entries {
168+
if entry.IsDir() {
169+
continue
170+
}
171+
name := entry.Name()
172+
if name == defaultLogFileName {
173+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
174+
continue
175+
}
176+
if order, ok := rotationOrder(name); ok {
177+
cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
178+
}
179+
}
180+
if len(cands) == 0 {
181+
return []string{}, nil
182+
}
183+
sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
184+
paths := make([]string, 0, len(cands))
185+
for i := len(cands) - 1; i >= 0; i-- {
186+
paths = append(paths, cands[i].path)
187+
}
188+
return paths, nil
189+
}
190+
191+
type logAccumulator struct {
192+
cutoff int64
193+
lines []string
194+
total int
195+
latest int64
196+
include bool
197+
}
198+
199+
func newLogAccumulator(cutoff int64) *logAccumulator {
200+
return &logAccumulator{
201+
cutoff: cutoff,
202+
lines: make([]string, 0, 256),
203+
}
204+
}
205+
206+
func (acc *logAccumulator) consumeFile(path string) error {
207+
file, err := os.Open(path)
208+
if err != nil {
209+
if os.IsNotExist(err) {
210+
return nil
211+
}
212+
return err
213+
}
214+
defer file.Close()
215+
216+
scanner := bufio.NewScanner(file)
217+
buf := make([]byte, 0, logScannerInitialBuffer)
218+
scanner.Buffer(buf, logScannerMaxBuffer)
219+
for scanner.Scan() {
220+
acc.addLine(scanner.Text())
221+
}
222+
if errScan := scanner.Err(); errScan != nil {
223+
return errScan
224+
}
225+
return nil
226+
}
227+
228+
func (acc *logAccumulator) addLine(raw string) {
229+
line := strings.TrimRight(raw, "\r")
230+
acc.total++
231+
ts := parseTimestamp(line)
232+
if ts > acc.latest {
233+
acc.latest = ts
234+
}
235+
if ts > 0 {
236+
acc.include = acc.cutoff == 0 || ts > acc.cutoff
237+
if acc.cutoff == 0 || acc.include {
238+
acc.lines = append(acc.lines, line)
239+
}
240+
return
241+
}
242+
if acc.cutoff == 0 || acc.include {
243+
acc.lines = append(acc.lines, line)
244+
}
245+
}
246+
247+
func (acc *logAccumulator) result() ([]string, int, int64) {
248+
if acc.lines == nil {
249+
acc.lines = []string{}
250+
}
251+
return acc.lines, acc.total, acc.latest
252+
}
253+
254+
func parseCutoff(raw string) int64 {
255+
value := strings.TrimSpace(raw)
256+
if value == "" {
257+
return 0
258+
}
259+
ts, err := strconv.ParseInt(value, 10, 64)
260+
if err != nil || ts <= 0 {
261+
return 0
262+
}
263+
return ts
264+
}
265+
266+
func parseTimestamp(line string) int64 {
267+
if strings.HasPrefix(line, "[") {
268+
line = line[1:]
269+
}
270+
if len(line) < 19 {
271+
return 0
272+
}
273+
candidate := line[:19]
274+
t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
275+
if err != nil {
276+
return 0
277+
}
278+
return t.Unix()
279+
}
280+
281+
func isRotatedLogFile(name string) bool {
282+
if _, ok := rotationOrder(name); ok {
283+
return true
284+
}
285+
return false
286+
}
287+
288+
func rotationOrder(name string) (int64, bool) {
289+
if order, ok := numericRotationOrder(name); ok {
290+
return order, true
291+
}
292+
if order, ok := timestampRotationOrder(name); ok {
293+
return order, true
294+
}
295+
return 0, false
296+
}
297+
298+
func numericRotationOrder(name string) (int64, bool) {
299+
if !strings.HasPrefix(name, defaultLogFileName+".") {
300+
return 0, false
301+
}
302+
suffix := strings.TrimPrefix(name, defaultLogFileName+".")
303+
if suffix == "" {
304+
return 0, false
305+
}
306+
n, err := strconv.Atoi(suffix)
307+
if err != nil {
308+
return 0, false
309+
}
310+
return int64(n), true
311+
}
312+
313+
func timestampRotationOrder(name string) (int64, bool) {
314+
ext := filepath.Ext(defaultLogFileName)
315+
base := strings.TrimSuffix(defaultLogFileName, ext)
316+
if base == "" {
317+
return 0, false
318+
}
319+
prefix := base + "-"
320+
if !strings.HasPrefix(name, prefix) {
321+
return 0, false
322+
}
323+
clean := strings.TrimPrefix(name, prefix)
324+
if strings.HasSuffix(clean, ".gz") {
325+
clean = strings.TrimSuffix(clean, ".gz")
326+
}
327+
if ext != "" {
328+
if !strings.HasSuffix(clean, ext) {
329+
return 0, false
330+
}
331+
clean = strings.TrimSuffix(clean, ext)
332+
}
333+
if clean == "" {
334+
return 0, false
335+
}
336+
if idx := strings.IndexByte(clean, '.'); idx != -1 {
337+
clean = clean[:idx]
338+
}
339+
parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
340+
if err != nil {
341+
return 0, false
342+
}
343+
return math.MaxInt64 - parsed.Unix(), true
344+
}

internal/api/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
233233
if optionState.localPassword != "" {
234234
s.mgmt.SetLocalPassword(optionState.localPassword)
235235
}
236+
s.mgmt.SetLogDirectory(filepath.Join(s.currentPath, "logs"))
236237
s.localPassword = optionState.localPassword
237238

238239
// Setup routes
@@ -411,6 +412,8 @@ func (s *Server) registerManagementRoutes() {
411412
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
412413
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
413414

415+
mgmt.GET("/logs", s.mgmt.GetLogs)
416+
mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
414417
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
415418
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
416419
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)

0 commit comments

Comments
 (0)