Files
panel/controller/internal/events/logbuf.go
T
2026-07-14 23:25:11 -07:00

155 lines
3.9 KiB
Go

package events
import (
"sync"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// LogBuffer is a bounded per-instance ring buffer of recent events. Its
// purpose is UX: when the operator opens the Console tab for an instance,
// we want them to see what's been going on — not just events that happen
// *after* they subscribed. The buffer tails the event bus continuously so
// a short history is always available.
//
// Events are stored verbatim (full *Event payload) so the caller can render
// them exactly like the live SSE stream would. Only log, player, and
// instance_state payloads are retained — stats and app_state churn too
// fast and aren't what "Console history" should show.
type LogBuffer struct {
bus *Bus
cap int
mu sync.Mutex
byInst map[string]*ringSlice
stopped bool
done chan struct{}
}
// NewLogBuffer creates the buffer and starts a background goroutine that
// consumes the bus. Call Stop to shut down.
//
// capPerInstance is the maximum number of events kept per instance id. The
// UI typically asks for the last 400, so 500 is a comfortable default.
func NewLogBuffer(bus *Bus, capPerInstance int) *LogBuffer {
if capPerInstance <= 0 {
capPerInstance = 500
}
b := &LogBuffer{
bus: bus,
cap: capPerInstance,
byInst: map[string]*ringSlice{},
done: make(chan struct{}),
}
go b.run()
return b
}
func (b *LogBuffer) run() {
defer close(b.done)
sub := b.bus.Subscribe(Filter{})
defer sub.Close()
for ev := range sub.Events() {
if !isRetained(ev) {
continue
}
id := eventInstanceID(ev)
if id == "" {
continue
}
b.mu.Lock()
if b.stopped {
b.mu.Unlock()
return
}
r := b.byInst[id]
if r == nil {
r = newRing(b.cap)
b.byInst[id] = r
}
r.push(ev)
b.mu.Unlock()
}
}
// Recent returns up to `limit` most-recent retained events for an instance,
// oldest first. Safe to call concurrently with Run.
func (b *LogBuffer) Recent(instanceID string, limit int) []*panelv1.Event {
b.mu.Lock()
defer b.mu.Unlock()
r := b.byInst[instanceID]
if r == nil {
return nil
}
return r.tail(limit)
}
// Forget drops the buffer for an instance. Called when an instance is
// deleted so memory doesn't leak for dead ids.
func (b *LogBuffer) Forget(instanceID string) {
b.mu.Lock()
delete(b.byInst, instanceID)
b.mu.Unlock()
}
// Stop ends the background goroutine. Safe to call multiple times.
func (b *LogBuffer) Stop() {
b.mu.Lock()
if b.stopped {
b.mu.Unlock()
return
}
b.stopped = true
b.mu.Unlock()
}
// isRetained decides whether an event belongs in the console history.
//
// - log lines: yes, that's the whole point.
// - player events: yes — joins/leaves/chat/deaths belong on the console.
// - instance_state: yes — "server started"-style transitions are context.
// - stats / app_state: no — churn too fast; would push real content out
// of the ring.
func isRetained(e *panelv1.Event) bool {
switch e.Payload.(type) {
case *panelv1.Event_Log, *panelv1.Event_Player, *panelv1.Event_InstanceState:
return true
}
return false
}
// ringSlice is a fixed-capacity FIFO. Implemented as a slice with head/len
// tracking to avoid allocating on steady-state appends.
type ringSlice struct {
items []*panelv1.Event
head int // index of the oldest entry (when len == cap)
len int
cap int
}
func newRing(cap int) *ringSlice {
return &ringSlice{items: make([]*panelv1.Event, cap), cap: cap}
}
func (r *ringSlice) push(e *panelv1.Event) {
if r.len < r.cap {
r.items[(r.head+r.len)%r.cap] = e
r.len++
return
}
// At capacity: overwrite the oldest.
r.items[r.head] = e
r.head = (r.head + 1) % r.cap
}
func (r *ringSlice) tail(limit int) []*panelv1.Event {
if limit <= 0 || limit > r.len {
limit = r.len
}
out := make([]*panelv1.Event, 0, limit)
start := (r.head + r.len - limit + r.cap) % r.cap
for i := 0; i < limit; i++ {
out = append(out, r.items[(start+i)%r.cap])
}
return out
}