Browse Source
- 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
12 changed files with 1027 additions and 18 deletions
@ -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}) |
||||
} |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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> |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue