Browse Source

Add log viewer for relay owners (v0.37.3)

- 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>
main
mleku 2 weeks ago
parent
commit
8e5754e799
No known key found for this signature in database
  1. 8
      app/config/config.go
  2. 185
      app/handle-logs.go
  3. 4
      app/server.go
  4. 1
      app/web/dist/bundle.css
  5. 30
      app/web/dist/bundle.js
  6. 2
      app/web/dist/bundle.js.map
  7. 9
      app/web/src/App.svelte
  8. 2
      app/web/src/BlossomView.svelte
  9. 554
      app/web/src/LogView.svelte
  10. 110
      pkg/logbuffer/buffer.go
  11. 138
      pkg/logbuffer/writer.go
  12. 2
      pkg/version/version

8
app/config/config.go

@ -24,6 +24,7 @@ import ( @@ -24,6 +24,7 @@ import (
"go-simpler.org/env"
lol "lol.mleku.dev"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/logbuffer"
"next.orly.dev/pkg/version"
)
@ -43,6 +44,7 @@ type C struct { @@ -43,6 +44,7 @@ type C struct {
DBIndexCacheMB int `env:"ORLY_DB_INDEX_CACHE_MB" default:"256" usage:"Badger index cache size in MB (improves index lookup performance)"`
DBZSTDLevel int `env:"ORLY_DB_ZSTD_LEVEL" default:"1" usage:"Badger ZSTD compression level (1=fast/500MB/s, 3=default, 9=best ratio, 0=disable)"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
LogBufferSize int `env:"ORLY_LOG_BUFFER_SIZE" default:"10000" usage:"number of log entries to keep in memory for web UI viewing (0 disables)"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
@ -178,6 +180,12 @@ func New() (cfg *C, err error) { @@ -178,6 +180,12 @@ func New() (cfg *C, err error) {
if cfg.LogToStdout {
lol.Writer = os.Stdout
}
// Initialize log buffer for web UI viewing
if cfg.LogBufferSize > 0 {
logbuffer.Init(cfg.LogBufferSize)
logbuffer.SetCurrentLevel(cfg.LogLevel)
lol.Writer = logbuffer.NewBufferedWriter(lol.Writer, logbuffer.GlobalBuffer)
}
lol.SetLogLevel(cfg.LogLevel)
return
}

185
app/handle-logs.go

@ -0,0 +1,185 @@ @@ -0,0 +1,185 @@
package app
import (
"encoding/json"
"net/http"
"strconv"
lol "lol.mleku.dev"
"lol.mleku.dev/chk"
"git.mleku.dev/mleku/nostr/httpauth"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/logbuffer"
)
// LogsResponse is the response structure for GET /api/logs
type LogsResponse struct {
Logs []logbuffer.LogEntry `json:"logs"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
}
// LogLevelResponse is the response structure for GET /api/logs/level
type LogLevelResponse struct {
Level string `json:"level"`
}
// LogLevelRequest is the request structure for POST /api/logs/level
type LogLevelRequest struct {
Level string `json:"level"`
}
// handleGetLogs handles GET /api/logs
func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level only
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Check if log buffer is available
if logbuffer.GlobalBuffer == nil {
http.Error(w, "Log buffer not enabled", http.StatusServiceUnavailable)
return
}
// Parse query parameters
offset := 0
limit := 100
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if v, err := strconv.Atoi(offsetStr); err == nil && v >= 0 {
offset = v
}
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 500 {
limit = v
}
}
// Get logs from buffer
logs := logbuffer.GlobalBuffer.Get(offset, limit)
total := logbuffer.GlobalBuffer.Count()
hasMore := offset+len(logs) < total
response := LogsResponse{
Logs: logs,
Total: total,
HasMore: hasMore,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleClearLogs handles POST /api/logs/clear
func (s *Server) handleClearLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level only
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Check if log buffer is available
if logbuffer.GlobalBuffer == nil {
http.Error(w, "Log buffer not enabled", http.StatusServiceUnavailable)
return
}
// Clear the buffer
logbuffer.GlobalBuffer.Clear()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleLogLevel handles GET and POST /api/logs/level
func (s *Server) handleLogLevel(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGetLogLevel(w, r)
case http.MethodPost:
s.handleSetLogLevel(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleGetLogLevel handles GET /api/logs/level
func (s *Server) handleGetLogLevel(w http.ResponseWriter, r *http.Request) {
// No auth required for reading log level
level := logbuffer.GetCurrentLevel()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LogLevelResponse{Level: level})
}
// handleSetLogLevel handles POST /api/logs/level
func (s *Server) handleSetLogLevel(w http.ResponseWriter, r *http.Request) {
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level only
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Parse request body
var req LogLevelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate and set log level
level := logbuffer.SetCurrentLevel(req.Level)
lol.SetLogLevel(level)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LogLevelResponse{Level: level})
}

4
app/server.go

@ -302,6 +302,10 @@ func (s *Server) UserInterface() { @@ -302,6 +302,10 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/nip86", s.handleNIP86Management)
// ACL mode endpoint
s.mux.HandleFunc("/api/acl-mode", s.handleACLMode)
// Log viewer endpoints (owner only)
s.mux.HandleFunc("/api/logs", s.handleGetLogs)
s.mux.HandleFunc("/api/logs/clear", s.handleClearLogs)
s.mux.HandleFunc("/api/logs/level", s.handleLogLevel)
// Sync endpoints for distributed synchronization
if s.syncManager != nil {

1
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

30
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

9
app/web/src/App.svelte

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
import SprocketView from "./SprocketView.svelte";
import PolicyView from "./PolicyView.svelte";
import BlossomView from "./BlossomView.svelte";
import LogView from "./LogView.svelte";
import SearchResultsView from "./SearchResultsView.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
@ -1658,6 +1659,7 @@ @@ -1658,6 +1659,7 @@
},
{ id: "sprocket", icon: "⚙", label: "Sprocket", requiresOwner: true },
{ id: "policy", icon: "📜", label: "Policy", requiresOwner: true },
{ id: "logs", icon: "📋", label: "Logs", requiresOwner: true },
];
// Filter tabs based on current effective role (including view-as setting)
@ -2900,6 +2902,13 @@ @@ -2900,6 +2902,13 @@
on:refreshFollows={refreshFollows}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "logs"}
<LogView
{isLoggedIn}
{userRole}
{userSigner}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "recovery"}
<div class="recovery-tab">
<div>

2
app/web/src/BlossomView.svelte

@ -457,7 +457,7 @@ @@ -457,7 +457,7 @@
</button>
{/if}
<button class="refresh-btn" on:click={handleRefresh} disabled={isLoading || isLoadingAdmin}>
{isLoading || isLoadingAdmin ? "Loading..." : "Refresh"}
🔄 {isLoading || isLoadingAdmin ? "Loading..." : "Refresh"}
</button>
</div>
</div>

554
app/web/src/LogView.svelte

@ -0,0 +1,554 @@ @@ -0,0 +1,554 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from "svelte";
export let isLoggedIn = false;
export let userRole = "";
export let userSigner = null;
const dispatch = createEventDispatcher();
let logs = [];
let isLoading = false;
let hasMore = true;
let offset = 0;
let totalLogs = 0;
let error = "";
let currentLogLevel = "info";
let selectedLevel = "info";
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
const LIMIT = 100;
let scrollContainer;
let loadMoreTrigger;
let observer;
$: canAccess = isLoggedIn && userRole === "owner";
onMount(() => {
if (canAccess) {
loadLogs(true);
loadLogLevel();
setupIntersectionObserver();
}
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
$: if (canAccess && logs.length === 0 && !isLoading) {
loadLogs(true);
loadLogLevel();
}
function setupIntersectionObserver() {
if (!loadMoreTrigger) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
loadMoreLogs();
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreTrigger);
}
async function createAuthHeader(method = "GET", path = "/api/logs") {
if (!userSigner) return null;
try {
const now = Math.floor(Date.now() / 1000);
const authEvent = {
kind: 27235,
created_at: now,
tags: [
["u", `${window.location.origin}${path}`],
["method", method],
],
content: "",
};
const signedEvent = await userSigner.signEvent(authEvent);
return btoa(JSON.stringify(signedEvent));
} catch (err) {
console.error("Error creating auth header:", err);
return null;
}
}
async function loadLogs(refresh = false) {
if (isLoading) return;
isLoading = true;
error = "";
if (refresh) {
offset = 0;
logs = [];
}
try {
const authHeader = await createAuthHeader("GET", "/api/logs");
const url = `${window.location.origin}/api/logs?offset=${offset}&limit=${LIMIT}`;
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to load logs: ${response.statusText}`);
}
const data = await response.json();
if (refresh) {
logs = data.logs || [];
} else {
logs = [...logs, ...(data.logs || [])];
}
totalLogs = data.total || 0;
hasMore = data.has_more || false;
offset = logs.length;
} catch (err) {
console.error("Error loading logs:", err);
error = err.message || "Failed to load logs";
} finally {
isLoading = false;
}
}
function loadMoreLogs() {
if (hasMore && !isLoading) {
loadLogs(false);
}
}
async function loadLogLevel() {
try {
const response = await fetch(`${window.location.origin}/api/logs/level`);
if (response.ok) {
const data = await response.json();
currentLogLevel = data.level || "info";
selectedLevel = currentLogLevel;
}
} catch (err) {
console.error("Error loading log level:", err);
}
}
async function setLogLevel() {
if (selectedLevel === currentLogLevel) return;
try {
const authHeader = await createAuthHeader("POST", "/api/logs/level");
const response = await fetch(`${window.location.origin}/api/logs/level`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: JSON.stringify({ level: selectedLevel }),
});
if (!response.ok) {
throw new Error(`Failed to set log level: ${response.statusText}`);
}
const data = await response.json();
currentLogLevel = data.level;
selectedLevel = currentLogLevel;
} catch (err) {
console.error("Error setting log level:", err);
error = err.message || "Failed to set log level";
selectedLevel = currentLogLevel;
}
}
async function clearLogs() {
if (!confirm("Are you sure you want to clear all logs?")) return;
try {
const authHeader = await createAuthHeader("POST", "/api/logs/clear");
const response = await fetch(`${window.location.origin}/api/logs/clear`, {
method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to clear logs: ${response.statusText}`);
}
logs = [];
offset = 0;
hasMore = false;
totalLogs = 0;
} catch (err) {
console.error("Error clearing logs:", err);
error = err.message || "Failed to clear logs";
}
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
const date = new Date(timestamp);
return date.toLocaleString();
}
function getLevelClass(level) {
switch (level?.toUpperCase()) {
case "TRC":
case "TRACE":
return "level-trace";
case "DBG":
case "DEBUG":
return "level-debug";
case "INF":
case "INFO":
return "level-info";
case "WRN":
case "WARN":
return "level-warn";
case "ERR":
case "ERROR":
return "level-error";
case "FTL":
case "FATAL":
return "level-fatal";
default:
return "level-info";
}
}
function openLoginModal() {
dispatch("openLoginModal");
}
</script>
{#if canAccess}
<div class="log-view">
<div class="header-section">
<h3>Logs</h3>
<div class="header-controls">
<div class="level-selector">
<label for="log-level">Level:</label>
<select
id="log-level"
bind:value={selectedLevel}
on:change={setLogLevel}
>
{#each LOG_LEVELS as level}
<option value={level}>{level}</option>
{/each}
</select>
</div>
<button class="clear-btn" on:click={clearLogs} disabled={isLoading || logs.length === 0}>
Clear
</button>
<button class="refresh-btn" on:click={() => loadLogs(true)} disabled={isLoading}>
🔄 {isLoading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="log-info">
Showing {logs.length} of {totalLogs} logs (Level: {currentLogLevel})
</div>
<div class="log-list" bind:this={scrollContainer}>
{#if logs.length === 0 && !isLoading}
<div class="empty-state">
<p>No logs available.</p>
</div>
{:else}
{#each logs as log}
<div class="log-entry">
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
<span class="log-level {getLevelClass(log.level)}">{log.level}</span>
{#if log.file}
<span class="log-location">{log.file}:{log.line}</span>
{/if}
<span class="log-message">{log.message}</span>
</div>
{/each}
<div bind:this={loadMoreTrigger} class="load-more-trigger">
{#if isLoading}
<span>Loading more...</span>
{:else if hasMore}
<span>Scroll for more</span>
{:else}
<span>End of logs</span>
{/if}
</div>
{/if}
</div>
</div>
{:else}
<div class="login-prompt">
<p>Log viewer is only available to relay owners.</p>
{#if !isLoggedIn}
<button class="login-btn" on:click={openLoginModal}>Log In</button>
{:else}
<p class="access-denied">Your role ({userRole}) does not have access to this feature.</p>
{/if}
</div>
{/if}
<style>
.log-view {
padding: 1em;
box-sizing: border-box;
width: 100%;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
flex-wrap: wrap;
gap: 0.5em;
}
.header-section h3 {
margin: 0;
color: var(--text-color);
}
.header-controls {
display: flex;
align-items: center;
gap: 0.75em;
flex-wrap: wrap;
}
.level-selector {
display: flex;
align-items: center;
gap: 0.5em;
}
.level-selector label {
color: var(--text-color);
font-size: 0.9em;
}
.level-selector select {
padding: 0.4em 0.6em;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--card-bg);
color: var(--text-color);
font-size: 0.9em;
}
.clear-btn {
background-color: transparent;
border: 1px solid var(--warning);
color: var(--warning);
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.clear-btn:hover:not(:disabled) {
background-color: var(--warning);
color: var(--text-color);
}
.clear-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.refresh-btn:hover:not(:disabled) {
background-color: var(--accent-hover-color);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: var(--warning);
color: var(--text-color);
padding: 0.75em 1em;
border-radius: 4px;
margin-bottom: 1em;
}
.log-info {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 0.75em;
}
.log-list {
display: flex;
flex-direction: column;
gap: 0.25em;
width: 100%;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 0.75em;
padding: 0.5em 0.75em;
background-color: var(--card-bg);
border-radius: 4px;
font-family: monospace;
font-size: 0.85em;
word-break: break-word;
}
.log-timestamp {
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
flex-shrink: 0;
}
.log-level {
font-weight: bold;
padding: 0.1em 0.4em;
border-radius: 3px;
text-transform: uppercase;
flex-shrink: 0;
min-width: 3.5em;
text-align: center;
}
.level-trace {
background-color: #6c757d;
color: white;
}
.level-debug {
background-color: #17a2b8;
color: white;
}
.level-info {
background-color: #28a745;
color: white;
}
.level-warn {
background-color: #ffc107;
color: #212529;
}
.level-error {
background-color: #dc3545;
color: white;
}
.level-fatal {
background-color: #721c24;
color: white;
}
.log-location {
color: var(--text-color);
opacity: 0.5;
flex-shrink: 0;
}
.log-message {
color: var(--text-color);
flex: 1;
}
.load-more-trigger {
padding: 1em;
text-align: center;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
.empty-state {
text-align: center;
padding: 2em;
color: var(--text-color);
opacity: 0.7;
}
.login-prompt {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
max-width: 32em;
margin: 1em;
}
.login-prompt p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
font-size: 1.1rem;
}
.login-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
}
.login-btn:hover {
background-color: var(--accent-hover-color);
}
.access-denied {
font-size: 0.9em;
opacity: 0.7;
}
@media (max-width: 600px) {
.header-section {
flex-direction: column;
align-items: flex-start;
}
.header-controls {
width: 100%;
justify-content: flex-end;
}
.log-entry {
flex-wrap: wrap;
}
.log-timestamp {
width: 100%;
margin-bottom: 0.25em;
}
}
</style>

110
pkg/logbuffer/buffer.go

@ -0,0 +1,110 @@ @@ -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

@ -0,0 +1,138 @@ @@ -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
}

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.37.2
v0.37.3

Loading…
Cancel
Save