v0.25.0 — Debug page: operator testing & diagnostics dashboard
Debug-mode-only dashboard (/debug) with 8 collapsible sections: system diagnostics, notification testing, backup triggers, storage simulation, hub & connectivity, self-update dry-run, DR/setup wizard, and in-memory log viewer. Migrates debug dump from API router to web server. Adds ring buffer log capture, storage disconnect simulation, event history tracking, and cross-drive/self-update test methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single parsed log line.
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"` // "DEBUG", "INFO", "WARN", "ERROR"
|
||||
Message string `json:"message"`
|
||||
Source string `json:"source"` // "file.go:123" if Lshortfile enabled
|
||||
}
|
||||
|
||||
// LogBuffer is a thread-safe ring buffer that captures log output.
|
||||
// It implements io.Writer so it can be used with log.New(io.MultiWriter(...)).
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
entries []LogEntry
|
||||
size int
|
||||
pos int
|
||||
full bool
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a ring buffer that keeps the last `size` log entries.
|
||||
func NewLogBuffer(size int) *LogBuffer {
|
||||
return &LogBuffer{
|
||||
entries: make([]LogEntry, size),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer. It parses Go's standard log output format.
|
||||
// Handles two formats:
|
||||
// - With Lshortfile: "2026/02/21 18:33:35 file.go:123: [LEVEL] message"
|
||||
// - Without: "2026/02/21 18:33:35 [LEVEL] message"
|
||||
func (lb *LogBuffer) Write(p []byte) (n int, err error) {
|
||||
line := strings.TrimRight(string(p), "\n\r")
|
||||
if line == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
entry := parseLine(line)
|
||||
|
||||
lb.mu.Lock()
|
||||
lb.entries[lb.pos] = entry
|
||||
lb.pos = (lb.pos + 1) % lb.size
|
||||
if lb.pos == 0 && !lb.full {
|
||||
lb.full = true
|
||||
}
|
||||
lb.mu.Unlock()
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Entries returns log entries filtered by minimum level, limited by count,
|
||||
// and optionally filtered to entries after a given timestamp.
|
||||
// Returns the matching entries and the total count in the buffer.
|
||||
func (lb *LogBuffer) Entries(minLevel string, limit int, after time.Time) ([]LogEntry, int) {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
// Collect all entries in chronological order
|
||||
total := lb.size
|
||||
if !lb.full {
|
||||
total = lb.pos
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
levelOrder := levelPriority(minLevel)
|
||||
|
||||
var result []LogEntry
|
||||
start := 0
|
||||
if lb.full {
|
||||
start = lb.pos
|
||||
}
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
idx := (start + i) % lb.size
|
||||
e := lb.entries[idx]
|
||||
|
||||
// Filter by level
|
||||
if levelPriority(e.Level) < levelOrder {
|
||||
continue
|
||||
}
|
||||
// Filter by timestamp
|
||||
if !after.IsZero() && !e.Timestamp.After(after) {
|
||||
continue
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
|
||||
// Apply limit (keep the most recent entries)
|
||||
if len(result) > limit {
|
||||
result = result[len(result)-limit:]
|
||||
}
|
||||
|
||||
return result, total
|
||||
}
|
||||
|
||||
// parseLine parses a single log line into a LogEntry.
|
||||
func parseLine(line string) LogEntry {
|
||||
entry := LogEntry{
|
||||
Level: "INFO",
|
||||
Message: line,
|
||||
}
|
||||
|
||||
// Try to parse timestamp: "2006/01/02 15:04:05"
|
||||
if len(line) >= 19 {
|
||||
if t, err := time.Parse("2006/01/02 15:04:05", line[:19]); err == nil {
|
||||
entry.Timestamp = t
|
||||
rest := line[19:]
|
||||
if len(rest) > 0 && rest[0] == ' ' {
|
||||
rest = rest[1:]
|
||||
}
|
||||
// Check for source file (Lshortfile): "file.go:123: [LEVEL] ..."
|
||||
if colonIdx := strings.Index(rest, ": "); colonIdx > 0 && colonIdx < 40 {
|
||||
candidate := rest[:colonIdx]
|
||||
// Source file pattern: contains ".go:" or ".go" before the colon
|
||||
if strings.Contains(candidate, ".go:") || strings.HasSuffix(candidate, ".go") {
|
||||
entry.Source = candidate
|
||||
rest = rest[colonIdx+2:]
|
||||
}
|
||||
}
|
||||
// Extract level tag: [DEBUG], [INFO], [WARN], [ERROR], [SYNC], [SCHED], etc.
|
||||
entry.Level, entry.Message = extractLevel(rest)
|
||||
}
|
||||
}
|
||||
|
||||
if entry.Timestamp.IsZero() {
|
||||
entry.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// extractLevel finds and removes a [LEVEL] tag from the beginning of a string.
|
||||
func extractLevel(s string) (string, string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 3 || s[0] != '[' {
|
||||
return "INFO", s
|
||||
}
|
||||
end := strings.Index(s, "]")
|
||||
if end < 0 || end > 20 {
|
||||
return "INFO", s
|
||||
}
|
||||
tag := s[1:end]
|
||||
msg := strings.TrimSpace(s[end+1:])
|
||||
|
||||
switch tag {
|
||||
case "DEBUG":
|
||||
return "DEBUG", msg
|
||||
case "INFO":
|
||||
return "INFO", msg
|
||||
case "WARN":
|
||||
return "WARN", msg
|
||||
case "ERROR":
|
||||
return "ERROR", msg
|
||||
case "FATAL":
|
||||
return "ERROR", msg
|
||||
default:
|
||||
// Tags like [SYNC], [SCHED], [STORAGE] etc. — treat as INFO, keep tag in message
|
||||
return "INFO", s
|
||||
}
|
||||
}
|
||||
|
||||
// levelPriority returns numeric priority for log levels.
|
||||
func levelPriority(level string) int {
|
||||
switch strings.ToUpper(level) {
|
||||
case "DEBUG":
|
||||
return 0
|
||||
case "INFO":
|
||||
return 1
|
||||
case "WARN":
|
||||
return 2
|
||||
case "ERROR":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user