Add log viewer for relay owners (v0.37.3)
Some checks failed
Go / build-and-release (push) Has been cancelled
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add in-memory ring buffer for log storage (configurable via ORLY_LOG_BUFFER_SIZE) - Add owner-only log viewer in web UI with infinite scroll - Add log level selector with runtime level changes - Add clear logs functionality - Update Blossom refresh button to use 🔄 emoji style Files modified: - pkg/logbuffer/buffer.go: Ring buffer implementation - pkg/logbuffer/writer.go: Buffered writer hook for log capture - app/config/config.go: Add ORLY_LOG_BUFFER_SIZE env var - app/handle-logs.go: Log API handlers - app/server.go: Register log routes - app/web/src/LogView.svelte: Log viewer component - app/web/src/App.svelte: Add logs tab (owner-only) - app/web/src/BlossomView.svelte: Update refresh button style 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
110
pkg/logbuffer/buffer.go
Normal file
110
pkg/logbuffer/buffer.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package logbuffer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry
|
||||
type LogEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// Buffer is a thread-safe ring buffer for log entries
|
||||
type Buffer struct {
|
||||
entries []LogEntry
|
||||
size int
|
||||
head int // next write position
|
||||
count int // number of entries
|
||||
nextID int64 // monotonic ID counter
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBuffer creates a new ring buffer with the specified size
|
||||
func NewBuffer(size int) *Buffer {
|
||||
if size <= 0 {
|
||||
size = 10000
|
||||
}
|
||||
return &Buffer{
|
||||
entries: make([]LogEntry, size),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a log entry to the buffer
|
||||
func (b *Buffer) Add(entry LogEntry) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.nextID++
|
||||
entry.ID = b.nextID
|
||||
|
||||
b.entries[b.head] = entry
|
||||
b.head = (b.head + 1) % b.size
|
||||
|
||||
if b.count < b.size {
|
||||
b.count++
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns log entries, newest first
|
||||
// offset is the number of entries to skip from the newest
|
||||
// limit is the maximum number of entries to return
|
||||
func (b *Buffer) Get(offset, limit int) []LogEntry {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.count == 0 || offset >= b.count {
|
||||
return []LogEntry{}
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
available := b.count - offset
|
||||
if limit > available {
|
||||
limit = available
|
||||
}
|
||||
|
||||
result := make([]LogEntry, limit)
|
||||
|
||||
// Start from the newest entry (head - 1) and go backwards
|
||||
for i := 0; i < limit; i++ {
|
||||
// Calculate index: newest is at (head - 1), skip offset entries
|
||||
idx := (b.head - 1 - offset - i + b.size*2) % b.size
|
||||
result[i] = b.entries[idx]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Clear removes all entries from the buffer
|
||||
func (b *Buffer) Clear() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.head = 0
|
||||
b.count = 0
|
||||
// Note: we don't reset nextID to maintain monotonic IDs
|
||||
}
|
||||
|
||||
// Count returns the number of entries in the buffer
|
||||
func (b *Buffer) Count() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.count
|
||||
}
|
||||
|
||||
// Global buffer instance
|
||||
var GlobalBuffer *Buffer
|
||||
|
||||
// Init initializes the global log buffer
|
||||
func Init(size int) {
|
||||
GlobalBuffer = NewBuffer(size)
|
||||
}
|
||||
138
pkg/logbuffer/writer.go
Normal file
138
pkg/logbuffer/writer.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package logbuffer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BufferedWriter wraps an io.Writer and captures log entries
|
||||
type BufferedWriter struct {
|
||||
original io.Writer
|
||||
buffer *Buffer
|
||||
lineBuf bytes.Buffer
|
||||
}
|
||||
|
||||
// Log format regex patterns
|
||||
// lol library format: "2024/01/15 10:30:45 file.go:123 [INF] message"
|
||||
// or similar variations
|
||||
var logPattern = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+([^\s:]+):(\d+)\s+\[([A-Z]{3})\]\s+(.*)$`)
|
||||
|
||||
// Simple format: "[level] message"
|
||||
var simplePattern = regexp.MustCompile(`^\[([A-Z]{3})\]\s+(.*)$`)
|
||||
|
||||
// NewBufferedWriter creates a new BufferedWriter
|
||||
func NewBufferedWriter(original io.Writer, buffer *Buffer) *BufferedWriter {
|
||||
return &BufferedWriter{
|
||||
original: original,
|
||||
buffer: buffer,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (w *BufferedWriter) Write(p []byte) (n int, err error) {
|
||||
// Always write to original first
|
||||
n, err = w.original.Write(p)
|
||||
|
||||
// Store in buffer if we have one
|
||||
if w.buffer != nil {
|
||||
// Accumulate data in line buffer
|
||||
w.lineBuf.Write(p)
|
||||
|
||||
// Process complete lines
|
||||
for {
|
||||
line, lineErr := w.lineBuf.ReadString('\n')
|
||||
if lineErr != nil {
|
||||
// Put back incomplete line
|
||||
if len(line) > 0 {
|
||||
w.lineBuf.WriteString(line)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Parse and store the complete line
|
||||
entry := w.parseLine(strings.TrimSuffix(line, "\n"))
|
||||
if entry.Message != "" {
|
||||
w.buffer.Add(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parseLine parses a log line into a LogEntry
|
||||
func (w *BufferedWriter) parseLine(line string) LogEntry {
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: line,
|
||||
Level: "INF",
|
||||
}
|
||||
|
||||
// Try full pattern first
|
||||
if matches := logPattern.FindStringSubmatch(line); matches != nil {
|
||||
// Parse timestamp
|
||||
if t, err := time.Parse("2006/01/02 15:04:05", matches[1]); err == nil {
|
||||
entry.Timestamp = t
|
||||
} else if t, err := time.Parse("2006/01/02 15:04:05.000", matches[1]); err == nil {
|
||||
entry.Timestamp = t
|
||||
}
|
||||
|
||||
entry.File = matches[2]
|
||||
if lineNum, err := strconv.Atoi(matches[3]); err == nil {
|
||||
entry.Line = lineNum
|
||||
}
|
||||
entry.Level = matches[4]
|
||||
entry.Message = matches[5]
|
||||
return entry
|
||||
}
|
||||
|
||||
// Try simple pattern
|
||||
if matches := simplePattern.FindStringSubmatch(line); matches != nil {
|
||||
entry.Level = matches[1]
|
||||
entry.Message = matches[2]
|
||||
return entry
|
||||
}
|
||||
|
||||
// Detect level from common prefixes
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "TRC") || strings.HasPrefix(line, "[TRC]") {
|
||||
entry.Level = "TRC"
|
||||
} else if strings.HasPrefix(line, "DBG") || strings.HasPrefix(line, "[DBG]") {
|
||||
entry.Level = "DBG"
|
||||
} else if strings.HasPrefix(line, "INF") || strings.HasPrefix(line, "[INF]") {
|
||||
entry.Level = "INF"
|
||||
} else if strings.HasPrefix(line, "WRN") || strings.HasPrefix(line, "[WRN]") {
|
||||
entry.Level = "WRN"
|
||||
} else if strings.HasPrefix(line, "ERR") || strings.HasPrefix(line, "[ERR]") {
|
||||
entry.Level = "ERR"
|
||||
} else if strings.HasPrefix(line, "FTL") || strings.HasPrefix(line, "[FTL]") {
|
||||
entry.Level = "FTL"
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// currentLevel tracks the current log level (string)
|
||||
var currentLevel = "info"
|
||||
|
||||
// GetCurrentLevel returns the current log level string
|
||||
func GetCurrentLevel() string {
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
// SetCurrentLevel sets the current log level and returns it
|
||||
func SetCurrentLevel(level string) string {
|
||||
level = strings.ToLower(level)
|
||||
// Validate level
|
||||
switch level {
|
||||
case "off", "fatal", "error", "warn", "info", "debug", "trace":
|
||||
currentLevel = level
|
||||
default:
|
||||
currentLevel = "info"
|
||||
}
|
||||
return currentLevel
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v0.37.2
|
||||
v0.37.3
|
||||
|
||||
Reference in New Issue
Block a user