Skip to content

Commit 3e0c132

Browse files
committed
feat: updates to debug UI
1 parent cbf1707 commit 3e0c132

9 files changed

Lines changed: 497 additions & 58 deletions

File tree

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
"permissions": {
33
"allow": [
44
"Bash(golangci-lint run ./...)",
5-
"Bash(go vet ./internal/debugui/...)"
5+
"Bash(go vet ./internal/debugui/...)",
6+
"Bash(go build ./...)",
7+
"Bash(go vet ./...)"
68
]
79
}
810
}

.github/images/debugui.png

88.8 KB
Loading

internal/debugui/debugui.go

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ func (h *Hub) remove(conn *websocket.Conn) {
4545
h.mu.Unlock()
4646
}
4747

48-
// Broadcast sends an entry to all connected clients. Non-blocking: slow clients drop messages.
49-
func (h *Hub) Broadcast(e Entry) {
50-
data, err := json.Marshal(e)
48+
// Broadcast sends a value to all connected clients. Non-blocking: slow clients drop messages.
49+
func (h *Hub) Broadcast(v any) {
50+
data, err := json.Marshal(v)
5151
if err != nil {
5252
return
5353
}
@@ -61,29 +61,44 @@ func (h *Hub) Broadcast(e Entry) {
6161
}
6262
}
6363

64+
// wsMessage wraps an entry or log for WebSocket broadcast with a kind discriminator.
65+
type wsMessage struct {
66+
Kind string `json:"kind"`
67+
Data any `json:"data"`
68+
}
69+
6470
// DebugUI is an HTTP server that serves the debug web interface and websocket.
6571
type DebugUI struct {
66-
store *Store
67-
hub *Hub
68-
stats *Stats
69-
srv *http.Server
72+
store *Store
73+
logStore *LogStore
74+
hub *Hub
75+
stats *Stats
76+
srv *http.Server
7077
}
7178

7279
// New creates a DebugUI bound to the given address.
73-
func New(addr string, store *Store) *DebugUI {
80+
func New(addr string, store *Store, logStore *LogStore) *DebugUI {
7481
d := &DebugUI{
75-
store: store,
76-
hub: newHub(),
77-
stats: NewStats(store),
82+
store: store,
83+
logStore: logStore,
84+
hub: newHub(),
85+
stats: NewStats(store),
7886
}
7987

80-
store.Subscribe(d.hub.Broadcast)
88+
store.Subscribe(func(e Entry) {
89+
d.hub.Broadcast(wsMessage{Kind: "message", Data: e})
90+
})
91+
logStore.Subscribe(func(e LogEntry) {
92+
d.hub.Broadcast(wsMessage{Kind: "log", Data: e})
93+
})
8194

8295
mux := http.NewServeMux()
8396
mux.Handle("GET /", http.FileServerFS(staticFiles()))
8497
mux.HandleFunc("GET /ws", d.handleWS)
8598
mux.HandleFunc("GET /api/messages", d.handleMessages)
8699
mux.HandleFunc("GET /api/messages/search", d.handleSearch)
100+
mux.HandleFunc("GET /api/logs", d.handleLogs)
101+
mux.HandleFunc("GET /api/logs/search", d.handleLogSearch)
87102
mux.HandleFunc("GET /api/stats", d.handleStats)
88103

89104
d.srv = &http.Server{
@@ -95,6 +110,11 @@ func New(addr string, store *Store) *DebugUI {
95110
return d
96111
}
97112

113+
// SlogHandler returns a slog.Handler that sends log records to the debug UI's log tab.
114+
func (d *DebugUI) SlogHandler() *SlogHandler {
115+
return NewSlogHandler(d.logStore)
116+
}
117+
98118
// ListenAndServe binds the port synchronously, then serves in the background.
99119
// Returns an error if the port cannot be bound. The server shuts down when ctx is cancelled.
100120
func (d *DebugUI) ListenAndServe(ctx context.Context) error {
@@ -189,6 +209,42 @@ func (d *DebugUI) handleSearch(w http.ResponseWriter, r *http.Request) {
189209
_ = json.NewEncoder(w).Encode(entries)
190210
}
191211

212+
func (d *DebugUI) handleLogs(w http.ResponseWriter, r *http.Request) {
213+
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
214+
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
215+
if limit <= 0 {
216+
limit = 100
217+
}
218+
if limit > 1000 {
219+
limit = 1000
220+
}
221+
222+
entries := d.logStore.Entries(offset, limit)
223+
if entries == nil {
224+
entries = []LogEntry{}
225+
}
226+
227+
w.Header().Set("Content-Type", "application/json")
228+
_ = json.NewEncoder(w).Encode(entries)
229+
}
230+
231+
func (d *DebugUI) handleLogSearch(w http.ResponseWriter, r *http.Request) {
232+
q := r.URL.Query().Get("q")
233+
if q == "" {
234+
w.Header().Set("Content-Type", "application/json")
235+
_, _ = w.Write([]byte("[]"))
236+
return
237+
}
238+
239+
entries := d.logStore.Search(q)
240+
if entries == nil {
241+
entries = []LogEntry{}
242+
}
243+
244+
w.Header().Set("Content-Type", "application/json")
245+
_ = json.NewEncoder(w).Encode(entries)
246+
}
247+
192248
func (d *DebugUI) handleStats(w http.ResponseWriter, _ *http.Request) {
193249
w.Header().Set("Content-Type", "application/json")
194250
_ = json.NewEncoder(w).Encode(d.stats.Snapshot())

internal/debugui/logstore.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package debugui
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"strings"
7+
"sync"
8+
"time"
9+
)
10+
11+
const maxLogEntries = 5000
12+
13+
// LogEntry is a captured log message.
14+
type LogEntry struct {
15+
ID int `json:"id"`
16+
Timestamp time.Time `json:"timestamp"`
17+
Level string `json:"level"` // "error", "warning", "info", "debug"
18+
Message string `json:"message"`
19+
}
20+
21+
// LogSubscriber receives new log entries.
22+
type LogSubscriber func(LogEntry)
23+
24+
// LogStore is a thread-safe ring buffer of log messages.
25+
type LogStore struct {
26+
mu sync.RWMutex
27+
entries []LogEntry
28+
nextID int
29+
subscribers []LogSubscriber
30+
}
31+
32+
// NewLogStore creates a new LogStore.
33+
func NewLogStore() *LogStore {
34+
return &LogStore{
35+
entries: make([]LogEntry, 0, 256),
36+
}
37+
}
38+
39+
// Subscribe registers a callback for new log entries.
40+
func (s *LogStore) Subscribe(fn LogSubscriber) {
41+
s.mu.Lock()
42+
s.subscribers = append(s.subscribers, fn)
43+
s.mu.Unlock()
44+
}
45+
46+
// Add stores a log entry and notifies subscribers.
47+
func (s *LogStore) Add(level, message string) {
48+
e := LogEntry{
49+
Timestamp: time.Now(),
50+
Level: level,
51+
Message: message,
52+
}
53+
54+
s.mu.Lock()
55+
56+
e.ID = s.nextID
57+
s.nextID++
58+
59+
if len(s.entries) < maxLogEntries {
60+
s.entries = append(s.entries, e)
61+
} else {
62+
s.entries[e.ID%maxLogEntries] = e
63+
}
64+
65+
subs := make([]LogSubscriber, len(s.subscribers))
66+
copy(subs, s.subscribers)
67+
s.mu.Unlock()
68+
69+
for _, fn := range subs {
70+
fn(e)
71+
}
72+
}
73+
74+
// Entries returns a paginated slice of log entries.
75+
func (s *LogStore) Entries(offset, limit int) []LogEntry {
76+
s.mu.RLock()
77+
defer s.mu.RUnlock()
78+
79+
n := len(s.entries)
80+
if offset >= n {
81+
return nil
82+
}
83+
end := min(offset+limit, n)
84+
result := make([]LogEntry, end-offset)
85+
copy(result, s.entries[offset:end])
86+
return result
87+
}
88+
89+
// Search returns log entries where the message contains the query substring.
90+
func (s *LogStore) Search(query string) []LogEntry {
91+
query = strings.ToLower(query)
92+
s.mu.RLock()
93+
defer s.mu.RUnlock()
94+
95+
var result []LogEntry
96+
for _, e := range s.entries {
97+
if strings.Contains(strings.ToLower(e.Message), query) ||
98+
strings.Contains(strings.ToLower(e.Level), query) {
99+
result = append(result, e)
100+
}
101+
}
102+
return result
103+
}
104+
105+
// SlogHandler is a slog.Handler that sends log records to a LogStore.
106+
type SlogHandler struct {
107+
store *LogStore
108+
attrs []slog.Attr
109+
group string
110+
}
111+
112+
// NewSlogHandler creates a slog.Handler that writes to the given LogStore.
113+
func NewSlogHandler(store *LogStore) *SlogHandler {
114+
return &SlogHandler{store: store}
115+
}
116+
117+
func (h *SlogHandler) Enabled(_ context.Context, _ slog.Level) bool {
118+
return true
119+
}
120+
121+
func (h *SlogHandler) Handle(_ context.Context, r slog.Record) error {
122+
level := strings.ToLower(r.Level.String())
123+
124+
msg := r.Message
125+
// Append attrs if any.
126+
var parts []string
127+
for _, a := range h.attrs {
128+
parts = append(parts, formatAttr(h.group, a))
129+
}
130+
r.Attrs(func(a slog.Attr) bool {
131+
parts = append(parts, formatAttr(h.group, a))
132+
return true
133+
})
134+
if len(parts) > 0 {
135+
msg += " " + strings.Join(parts, " ")
136+
}
137+
138+
h.store.Add(level, msg)
139+
return nil
140+
}
141+
142+
func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
143+
return &SlogHandler{
144+
store: h.store,
145+
attrs: append(append([]slog.Attr{}, h.attrs...), attrs...),
146+
group: h.group,
147+
}
148+
}
149+
150+
func (h *SlogHandler) WithGroup(name string) slog.Handler {
151+
prefix := name
152+
if h.group != "" {
153+
prefix = h.group + "." + name
154+
}
155+
return &SlogHandler{
156+
store: h.store,
157+
attrs: append([]slog.Attr{}, h.attrs...),
158+
group: prefix,
159+
}
160+
}
161+
162+
func formatAttr(group string, a slog.Attr) string {
163+
key := a.Key
164+
if group != "" {
165+
key = group + "." + key
166+
}
167+
return key + "=" + a.Value.String()
168+
}

0 commit comments

Comments
 (0)