Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user