diff --git a/internal/gateway/log_tee.go b/internal/gateway/log_tee.go index 490ab6be..58ee0776 100644 --- a/internal/gateway/log_tee.go +++ b/internal/gateway/log_tee.go @@ -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 +} diff --git a/internal/gateway/methods/logs.go b/internal/gateway/methods/logs.go index 00aa643d..18d3795b 100644 --- a/internal/gateway/methods/logs.go +++ b/internal/gateway/methods/logs.go @@ -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 + } +} diff --git a/ui/web/src/pages/logs/hooks/use-logs.ts b/ui/web/src/pages/logs/hooks/use-logs.ts index 1f6818a2..545e0625 100644 --- a/ui/web/src/pages/logs/hooks/use-logs.ts +++ b/ui/web/src/pages/logs/hooks/use-logs.ts @@ -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([]); - const [tailing, setTailing] = useState(false); - const [error, setError] = useState(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 }; diff --git a/ui/web/src/pages/logs/logs-page.tsx b/ui/web/src/pages/logs/logs-page.tsx index 5ea29f29..ca62251b 100644 --- a/ui/web/src/pages/logs/logs-page.tsx +++ b/ui/web/src/pages/logs/logs-page.tsx @@ -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 = { 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(null); +const levels: LogLevel[] = ["debug", "info", "warn", "error"]; +export function LogsPage() { + const { logs, tailing, level, error, startTail, stopTail, clearLogs } = + useLogs(); + const scrollRef = useRef(null); + const [filterLevel, setFilterLevel] = useState("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 (
{tailing && Live} {tailing ? ( - ) : ( - +
+ + +
)} + ))} +
+ + {filtered.length}/{logs.length} + + + + {/* Log output */}
{error ? (

{error}

-

- This feature is not yet implemented on the Go backend. -

- ) : logs.length === 0 ? ( + ) : filtered.length === 0 ? (

{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.'}

) : ( - logs.map((entry: LogEntry, i: number) => ( -
- - {new Date(entry.timestamp).toLocaleTimeString()} - {" "} - - [{entry.level?.toUpperCase() || "LOG"}] - {" "} - {entry.source && ( - [{entry.source}] - )} - {entry.message} -
+ filtered.map((entry: LogEntry, i: number) => ( + )) )}
); } + +function LogLine({ entry }: { entry: LogEntry }) { + const attrs = entry.attrs; + const hasAttrs = attrs && Object.keys(attrs).length > 0; + + return ( +
+ + {new Date(entry.timestamp).toLocaleTimeString()} + {" "} + + [{entry.level?.toUpperCase() || "LOG"}] + {" "} + {entry.source && ( + [{entry.source}] + )} + {entry.message} + {hasAttrs && ( + + {Object.entries(attrs!).map(([k, v]) => ( + + {k} + = + {v} + + ))} + + )} +
+ ); +} diff --git a/ui/web/src/stores/use-logs-store.ts b/ui/web/src/stores/use-logs-store.ts new file mode 100644 index 00000000..33055248 --- /dev/null +++ b/ui/web/src/stores/use-logs-store.ts @@ -0,0 +1,43 @@ +import { create } from "zustand"; + +export interface LogEntry { + timestamp: number; + level: string; + message: string; + source?: string; + attrs?: Record; +} + +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((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: [] }), +}));