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 }