mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
feat: enhance log tailing with attrs, redaction, ring buffer, level control and persistent UI
Backend: include all slog attrs in WS payload with sensitive key redaction,
add 100-entry ring buffer for replay on subscribe, support per-client level
selection via logs.tail { level: "debug" }.
Frontend: move log state to Zustand store so tailing persists across page
navigation, add level selector, client-side level filter, text search, and
inline attr rendering.
This commit is contained in:
1 parent
c5127733a0
commit
e0da99d150
5 files changed
+416
-109
No files matched your search
+160
-31
@@ -3,19 +3,43 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
ringBufferSize = 100
|
||||
redactedValue = "***"
|
||||
)
|
||||
|
||||
// sensitiveKeys are attribute keys whose values are redacted before forwarding.
|
||||
var sensitiveKeys = []string{
|
||||
"key", "token", "secret", "password", "dsn",
|
||||
"credential", "authorization", "cookie",
|
||||
}
|
||||
|
||||
// LogTee is a slog.Handler that forwards log records to subscribed WS clients
|
||||
// while delegating to an underlying handler for normal output.
|
||||
type LogTee struct {
|
||||
inner slog.Handler
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]*Client // client ID → client
|
||||
clients map[string]*logSubscriber
|
||||
|
||||
// Ring buffer of recent entries for replay on subscribe.
|
||||
ringMu sync.RWMutex
|
||||
ring []map[string]interface{}
|
||||
ringPos int
|
||||
ringFul bool
|
||||
}
|
||||
|
||||
// logSubscriber tracks a client and its requested minimum log level.
|
||||
type logSubscriber struct {
|
||||
client *Client
|
||||
level slog.Level
|
||||
}
|
||||
|
||||
// NewLogTee wraps an existing slog.Handler so log records are also forwarded
|
||||
@@ -23,57 +47,107 @@ type LogTee struct {
|
||||
func NewLogTee(inner slog.Handler) *LogTee {
|
||||
return &LogTee{
|
||||
inner: inner,
|
||||
clients: make(map[string]*Client),
|
||||
clients: make(map[string]*logSubscriber),
|
||||
ring: make([]map[string]interface{}, ringBufferSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LogTee) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return t.inner.Enabled(ctx, level)
|
||||
// Always accept if inner handler wants it.
|
||||
if t.inner.Enabled(ctx, level) {
|
||||
return true
|
||||
}
|
||||
// Also accept if any subscriber wants this level (e.g. debug).
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for _, sub := range t.clients {
|
||||
if level >= sub.level {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *LogTee) Handle(ctx context.Context, r slog.Record) error {
|
||||
// Forward to subscribers (non-blocking).
|
||||
// Build entry for WS clients.
|
||||
t.mu.RLock()
|
||||
n := len(t.clients)
|
||||
t.mu.RUnlock()
|
||||
|
||||
if n > 0 {
|
||||
entry := map[string]interface{}{
|
||||
"timestamp": r.Time.UnixMilli(),
|
||||
"level": levelName(r.Level),
|
||||
"message": r.Message,
|
||||
}
|
||||
needEntry := n > 0 // need to broadcast
|
||||
// Always build entry for ring buffer regardless of subscribers.
|
||||
entry := t.buildEntry(r)
|
||||
|
||||
// Collect attributes into source hint.
|
||||
var src string
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key == "component" || a.Key == "source" || a.Key == "module" {
|
||||
src = a.Value.String()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if src != "" {
|
||||
entry["source"] = src
|
||||
}
|
||||
// Store in ring buffer.
|
||||
t.ringMu.Lock()
|
||||
t.ring[t.ringPos] = entry
|
||||
t.ringPos = (t.ringPos + 1) % ringBufferSize
|
||||
if t.ringPos == 0 {
|
||||
t.ringFul = true
|
||||
}
|
||||
t.ringMu.Unlock()
|
||||
|
||||
// Forward to subscribers.
|
||||
if needEntry {
|
||||
evt := protocol.NewEvent("log", entry)
|
||||
level := r.Level
|
||||
|
||||
t.mu.RLock()
|
||||
for _, c := range t.clients {
|
||||
c.SendEvent(*evt)
|
||||
for _, sub := range t.clients {
|
||||
if level >= sub.level {
|
||||
sub.client.SendEvent(*evt)
|
||||
}
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
}
|
||||
|
||||
return t.inner.Handle(ctx, r)
|
||||
// Forward to inner handler only if it accepts this level.
|
||||
if t.inner.Enabled(ctx, r.Level) {
|
||||
return t.inner.Handle(ctx, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildEntry creates the WS payload from a log record, redacting sensitive attrs.
|
||||
func (t *LogTee) buildEntry(r slog.Record) map[string]interface{} {
|
||||
entry := map[string]interface{}{
|
||||
"timestamp": r.Time.UnixMilli(),
|
||||
"level": levelName(r.Level),
|
||||
"message": r.Message,
|
||||
}
|
||||
|
||||
attrs := map[string]interface{}{}
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
key := a.Key
|
||||
val := a.Value.String()
|
||||
|
||||
// Extract source hint.
|
||||
if key == "component" || key == "source" || key == "module" {
|
||||
entry["source"] = val
|
||||
return true
|
||||
}
|
||||
|
||||
// Redact sensitive values.
|
||||
if isSensitiveKey(key) {
|
||||
attrs[key] = redactedValue
|
||||
} else {
|
||||
attrs[key] = val
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if len(attrs) > 0 {
|
||||
entry["attrs"] = attrs
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func (t *LogTee) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &LogTee{
|
||||
inner: t.inner.WithAttrs(attrs),
|
||||
clients: t.clients,
|
||||
mu: t.mu,
|
||||
ring: t.ring,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,17 +155,45 @@ func (t *LogTee) WithGroup(name string) slog.Handler {
|
||||
return &LogTee{
|
||||
inner: t.inner.WithGroup(name),
|
||||
clients: t.clients,
|
||||
mu: t.mu,
|
||||
ring: t.ring,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a client to the log tailing set.
|
||||
func (t *LogTee) Subscribe(client *Client) {
|
||||
// Subscribe adds a client to the log tailing set at the given level.
|
||||
// Pass slog.LevelInfo for default, slog.LevelDebug for verbose.
|
||||
func (t *LogTee) Subscribe(client *Client, level slog.Level) {
|
||||
t.mu.Lock()
|
||||
t.clients[client.ID()] = client
|
||||
t.clients[client.ID()] = &logSubscriber{client: client, level: level}
|
||||
t.mu.Unlock()
|
||||
|
||||
// Send an initial log entry so the client knows tailing started.
|
||||
// Replay ring buffer entries at the requested level.
|
||||
t.ringMu.RLock()
|
||||
var entries []map[string]interface{}
|
||||
if t.ringFul {
|
||||
// Buffer is full — read from ringPos (oldest) to ringPos-1 (newest).
|
||||
for i := 0; i < ringBufferSize; i++ {
|
||||
idx := (t.ringPos + i) % ringBufferSize
|
||||
e := t.ring[idx]
|
||||
if e != nil && logLevelValue(e["level"]) >= level {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Buffer not full — read from 0 to ringPos-1.
|
||||
for i := 0; i < t.ringPos; i++ {
|
||||
e := t.ring[i]
|
||||
if e != nil && logLevelValue(e["level"]) >= level {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.ringMu.RUnlock()
|
||||
|
||||
for _, e := range entries {
|
||||
client.SendEvent(*protocol.NewEvent("log", e))
|
||||
}
|
||||
|
||||
// Send sentinel so the client knows tailing started.
|
||||
client.SendEvent(*protocol.NewEvent("log", map[string]interface{}{
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"level": "info",
|
||||
@@ -119,3 +221,30 @@ func levelName(l slog.Level) string {
|
||||
return "debug"
|
||||
}
|
||||
}
|
||||
|
||||
// logLevelValue converts a level name string back to slog.Level for filtering.
|
||||
func logLevelValue(v interface{}) slog.Level {
|
||||
s, _ := v.(string)
|
||||
switch s {
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "info":
|
||||
return slog.LevelInfo
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
for _, s := range sensitiveKeys {
|
||||
if strings.Contains(lower, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package methods
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
@@ -24,6 +25,7 @@ func (m *LogsMethods) Register(router *gateway.MethodRouter) {
|
||||
func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params struct {
|
||||
Action string `json:"action"`
|
||||
Level string `json:"level"` // "debug", "info", "warn", "error" (default: "info")
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
@@ -31,9 +33,11 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
|
||||
|
||||
switch params.Action {
|
||||
case "start":
|
||||
m.logTee.Subscribe(client)
|
||||
level := parseLogLevel(params.Level)
|
||||
m.logTee.Subscribe(client, level)
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
"status": "tailing",
|
||||
"level": params.Level,
|
||||
}))
|
||||
case "stop":
|
||||
m.logTee.Unsubscribe(client.ID())
|
||||
@@ -48,3 +52,16 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func parseLogLevel(s string) slog.Level {
|
||||
switch s {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,39 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import {
|
||||
useLogsStore,
|
||||
type LogEntry,
|
||||
type LogLevel,
|
||||
} from "@/stores/use-logs-store";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge hook: connects WsClient (React context) to the global Zustand log store.
|
||||
* Log state persists across page navigation — tailing continues in background.
|
||||
* Mount this hook on the logs page; the store retains entries when unmounted.
|
||||
*/
|
||||
export function useLogs() {
|
||||
const ws = useWs();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [tailing, setTailing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const maxLogs = useRef(500);
|
||||
const store = useLogsStore();
|
||||
const tailingRef = useRef(store.tailing);
|
||||
tailingRef.current = store.tailing;
|
||||
|
||||
const startTail = useCallback(async () => {
|
||||
if (!ws.isConnected) return;
|
||||
setError(null);
|
||||
try {
|
||||
await ws.call(Methods.LOGS_TAIL, { action: "start" });
|
||||
setTailing(true);
|
||||
} catch {
|
||||
setError("logs.tail is not available on this backend.");
|
||||
}
|
||||
}, [ws]);
|
||||
const startTail = useCallback(
|
||||
async (level?: LogLevel) => {
|
||||
if (!ws.isConnected) return;
|
||||
const lvl = level ?? store.level;
|
||||
store.setError(null);
|
||||
try {
|
||||
await ws.call(Methods.LOGS_TAIL, { action: "start", level: lvl });
|
||||
store.setTailing(true);
|
||||
store.setLevel(lvl);
|
||||
} catch {
|
||||
store.setError("logs.tail is not available on this backend.");
|
||||
}
|
||||
},
|
||||
[ws, store],
|
||||
);
|
||||
|
||||
const stopTail = useCallback(async () => {
|
||||
if (!ws.isConnected) return;
|
||||
@@ -35,31 +42,43 @@ export function useLogs() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setTailing(false);
|
||||
}, [ws]);
|
||||
store.setTailing(false);
|
||||
}, [ws, store]);
|
||||
|
||||
// Listen for log events
|
||||
useWsEvent("log", (payload: unknown) => {
|
||||
const entry = payload as LogEntry;
|
||||
if (!entry) return;
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > maxLogs.current ? next.slice(-maxLogs.current) : next;
|
||||
});
|
||||
});
|
||||
// Listen for log events — always active while this hook is mounted.
|
||||
useWsEvent(
|
||||
"log",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const entry = payload as LogEntry;
|
||||
if (entry) useLogsStore.getState().appendLog(entry);
|
||||
},
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
// On WS reconnect: re-subscribe if tailing was active.
|
||||
const prevConnected = useRef(ws.isConnected);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Stop tailing on unmount
|
||||
if (tailing) {
|
||||
stopTail();
|
||||
}
|
||||
};
|
||||
}, [tailing, stopTail]);
|
||||
if (ws.isConnected && !prevConnected.current && tailingRef.current) {
|
||||
// Reconnected while tailing — re-subscribe.
|
||||
ws.call(Methods.LOGS_TAIL, {
|
||||
action: "start",
|
||||
level: useLogsStore.getState().level,
|
||||
}).catch(() => {});
|
||||
}
|
||||
prevConnected.current = ws.isConnected;
|
||||
}, [ws, ws.isConnected]);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogs([]);
|
||||
}, []);
|
||||
|
||||
return { logs, tailing, error, startTail, stopTail, clearLogs };
|
||||
return {
|
||||
logs: store.logs,
|
||||
tailing: store.tailing,
|
||||
level: store.level,
|
||||
error: store.error,
|
||||
startTail,
|
||||
stopTail,
|
||||
clearLogs: store.clearLogs,
|
||||
};
|
||||
}
|
||||
|
||||
export type { LogEntry, LogLevel };
|
||||
@@ -1,43 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal, Play, Square, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { Terminal, Play, Square, Trash2, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { useLogs, type LogEntry } from "./hooks/use-logs";
|
||||
import { useLogs, type LogEntry, type LogLevel } from "./hooks/use-logs";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
error: "text-red-500",
|
||||
warn: "text-yellow-500",
|
||||
info: "text-blue-500",
|
||||
debug: "text-muted-foreground",
|
||||
debug: "text-zinc-500",
|
||||
};
|
||||
|
||||
export function LogsPage() {
|
||||
const { logs, tailing, error, startTail, stopTail, clearLogs } = useLogs();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const levels: LogLevel[] = ["debug", "info", "warn", "error"];
|
||||
|
||||
export function LogsPage() {
|
||||
const { logs, tailing, level, error, startTail, stopTail, clearLogs } =
|
||||
useLogs();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [filterLevel, setFilterLevel] = useState<LogLevel>("debug");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Auto-scroll to bottom on new logs.
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const levelIdx = levels.indexOf(filterLevel);
|
||||
return logs.filter((entry) => {
|
||||
if (levels.indexOf(entry.level as LogLevel) < levelIdx) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const haystack =
|
||||
`${entry.message} ${entry.source ?? ""} ${Object.values(entry.attrs ?? {}).join(" ")}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [logs, filterLevel, search]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
description="Live log tailing"
|
||||
description={
|
||||
tailing
|
||||
? `Live tailing at ${level} level`
|
||||
: "Live log tailing"
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{tailing && <Badge variant="success">Live</Badge>}
|
||||
{tailing ? (
|
||||
<Button variant="outline" size="sm" onClick={stopTail} className="gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={stopTail}
|
||||
className="gap-1"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" /> Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={startTail} className="gap-1">
|
||||
<Play className="h-3.5 w-3.5" /> Start Tail
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => startTail(e.target.value as LogLevel)}
|
||||
className="h-8 cursor-pointer rounded-md border bg-background px-2 text-xs"
|
||||
title="Log level"
|
||||
>
|
||||
{levels.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startTail()}
|
||||
className="gap-1"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" /> Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -52,48 +99,100 @@ export function LogsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter logs..."
|
||||
className="h-7 w-full rounded-md border bg-background pl-8 pr-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{levels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setFilterLevel(l)}
|
||||
className={`cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
filterLevel === l
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filtered.length}/{logs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Log output */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-4 flex-1 overflow-y-auto rounded-md border bg-zinc-950 p-4 font-mono text-xs text-zinc-300"
|
||||
className="mt-2 flex-1 overflow-y-auto rounded-md border bg-zinc-950 p-4 font-mono text-xs text-zinc-300"
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center py-12 text-zinc-500">
|
||||
<div className="text-center">
|
||||
<Terminal className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-yellow-500">{error}</p>
|
||||
<p className="mt-1 text-zinc-600">
|
||||
This feature is not yet implemented on the Go backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-zinc-500">
|
||||
<div className="text-center">
|
||||
<Terminal className="mx-auto mb-2 h-8 w-8" />
|
||||
<p>
|
||||
{tailing
|
||||
? "Waiting for logs..."
|
||||
: "Click \"Start Tail\" to begin streaming logs."}
|
||||
? search
|
||||
? "No logs match filter."
|
||||
: "Waiting for logs..."
|
||||
: 'Click "Start" to begin streaming logs.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((entry: LogEntry, i: number) => (
|
||||
<div key={i} className="leading-relaxed">
|
||||
<span className="text-zinc-500">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
<span className={levelColors[entry.level] || "text-zinc-400"}>
|
||||
[{entry.level?.toUpperCase() || "LOG"}]
|
||||
</span>{" "}
|
||||
{entry.source && (
|
||||
<span className="text-zinc-500">[{entry.source}] </span>
|
||||
)}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
filtered.map((entry: LogEntry, i: number) => (
|
||||
<LogLine key={i} entry={entry} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLine({ entry }: { entry: LogEntry }) {
|
||||
const attrs = entry.attrs;
|
||||
const hasAttrs = attrs && Object.keys(attrs).length > 0;
|
||||
|
||||
return (
|
||||
<div className="leading-relaxed">
|
||||
<span className="text-zinc-500">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
<span className={levelColors[entry.level] || "text-zinc-400"}>
|
||||
[{entry.level?.toUpperCase() || "LOG"}]
|
||||
</span>{" "}
|
||||
{entry.source && (
|
||||
<span className="text-cyan-600">[{entry.source}] </span>
|
||||
)}
|
||||
<span>{entry.message}</span>
|
||||
{hasAttrs && (
|
||||
<span className="ml-2 text-zinc-600">
|
||||
{Object.entries(attrs!).map(([k, v]) => (
|
||||
<span key={k} className="mr-2">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<span className="text-zinc-600">=</span>
|
||||
<span className="text-zinc-400">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogsState {
|
||||
logs: LogEntry[];
|
||||
tailing: boolean;
|
||||
level: LogLevel;
|
||||
error: string | null;
|
||||
|
||||
appendLog: (entry: LogEntry) => void;
|
||||
setTailing: (v: boolean) => void;
|
||||
setLevel: (l: LogLevel) => void;
|
||||
setError: (e: string | null) => void;
|
||||
clearLogs: () => void;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
export const useLogsStore = create<LogsState>((set) => ({
|
||||
logs: [],
|
||||
tailing: false,
|
||||
level: "info",
|
||||
error: null,
|
||||
|
||||
appendLog: (entry) =>
|
||||
set((s) => {
|
||||
const next = [...s.logs, entry];
|
||||
return { logs: next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next };
|
||||
}),
|
||||
setTailing: (v) => set({ tailing: v }),
|
||||
setLevel: (l) => set({ level: l }),
|
||||
setError: (e) => set({ error: e }),
|
||||
clearLogs: () => set({ logs: [] }),
|
||||
}));
|
||||
Reference in new issue
Block a user