@@ -0,0 +1,145 @@
|
||||
// Package hub fans out live UDP frames to connected WebSocket clients.
|
||||
//
|
||||
// A slow or stalled client cannot block the broadcast path: each client owns
|
||||
// a buffered send channel and is dropped if the channel fills up.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
)
|
||||
|
||||
// Client is a connected live websocket viewer with its own send queue.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *Client) close() {
|
||||
if c.closed.CompareAndSwap(false, true) {
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Addr returns the client's remote address as a string.
|
||||
func (c *Client) Addr() string { return c.conn.RemoteAddr().String() }
|
||||
|
||||
// Hub fans out the latest state to all connected live clients.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
latest atomic.Pointer[[]byte] // most recent broadcast frame, for bootstrapping new clients
|
||||
events chan []byte // out-of-band server→client events
|
||||
}
|
||||
|
||||
// New constructs a Hub and starts its event pump.
|
||||
func New() *Hub {
|
||||
h := &Hub{
|
||||
clients: make(map[*Client]struct{}),
|
||||
events: make(chan []byte, 32),
|
||||
}
|
||||
go h.eventPump()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) eventPump() {
|
||||
for msg := range h.events {
|
||||
h.Broadcast(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishEvent JSON-encodes payload and best-effort fans it out to all clients.
|
||||
func (h *Hub) PublishEvent(payload any) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.events <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a new client and immediately sends the latest snapshot.
|
||||
func (h *Hub) Add(conn *websocket.Conn) *Client {
|
||||
cl := &Client{conn: conn, send: make(chan []byte, 64)}
|
||||
go cl.writer()
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[cl] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
if latest := h.latest.Load(); latest != nil {
|
||||
select {
|
||||
case cl.send <- *latest:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return cl
|
||||
}
|
||||
|
||||
// Remove unregisters and closes a client.
|
||||
func (h *Hub) Remove(cl *Client) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, cl)
|
||||
h.mu.Unlock()
|
||||
cl.close()
|
||||
}
|
||||
|
||||
// Broadcast publishes data with non-blocking sends; clients whose queues are
|
||||
// full are dropped.
|
||||
func (h *Hub) Broadcast(data []byte) {
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
h.latest.Store(&cp)
|
||||
|
||||
var drop []*Client
|
||||
h.mu.RLock()
|
||||
for cl := range h.clients {
|
||||
select {
|
||||
case cl.send <- data:
|
||||
default:
|
||||
drop = append(drop, cl)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
if len(drop) == 0 {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, cl := range drop {
|
||||
delete(h.clients, cl)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, cl := range drop {
|
||||
slog.Warn("dropping slow client", "addr", cl.Addr())
|
||||
cl.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of connected live clients.
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// writer drains the client's send queue, writing frames to its websocket.
|
||||
func (c *Client) writer() {
|
||||
for msg := range c.send {
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
c.closed.Store(true)
|
||||
_ = c.conn.Close()
|
||||
for range c.send {
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package recording handles persistence (SQLite), capture (Recorder), and
|
||||
// playback (Player) of UDP telemetry frames.
|
||||
package recording
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
|
||||
)
|
||||
|
||||
// Info is the JSON shape returned by /api/recordings.
|
||||
type Info struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Frames int `json:"frames"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
// OpenDB opens (or creates) the SQLite database and ensures the schema is
|
||||
// up-to-date, performing any necessary additive migrations.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", path+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
frames INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recording_id INTEGER NOT NULL,
|
||||
offset_ms INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_frames_recording ON frames(recording_id, offset_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_recordings_created_at ON recordings(created_at);
|
||||
`); err != nil {
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
}
|
||||
|
||||
if !columnExists(db, "frames", "codec") {
|
||||
if _, err := db.Exec(`ALTER TABLE frames ADD COLUMN codec INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return nil, fmt.Errorf("add codec column: %w", err)
|
||||
}
|
||||
slog.Info("migration: added frames.codec column")
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func columnExists(db *sql.DB, table, col string) bool {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
continue
|
||||
}
|
||||
if name == col {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// List returns recordings ordered newest-first.
|
||||
// since: optional RFC3339 timestamp lower bound (empty disables).
|
||||
// limit: max rows (0 = no limit).
|
||||
func List(db *sql.DB, since string, limit int) ([]Info, error) {
|
||||
q := `SELECT id, name, created_at, frames, duration_ms FROM recordings`
|
||||
args := []any{}
|
||||
if since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
q += ` WHERE created_at >= ?`
|
||||
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
q += ` ORDER BY created_at DESC`
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Info{}
|
||||
for rows.Next() {
|
||||
var r Info
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.CreatedAt, &r.Frames, &r.DurationMs); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes a recording by ID. CASCADE drops associated frames.
|
||||
// Returns the rows affected.
|
||||
func Delete(db *sql.DB, id int64) (int64, error) {
|
||||
res, err := db.Exec(`DELETE FROM recordings WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CleanOld removes recordings older than retention and emits deletion events
|
||||
// to the hub.
|
||||
func CleanOld(db *sql.DB, h *hub.Hub, retention time.Duration) {
|
||||
cutoff := time.Now().Add(-retention).UTC().Format("2006-01-02 15:04:05")
|
||||
rows, err := db.Query(`SELECT id FROM recordings WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
slog.Error("cleanup query error", "err", err)
|
||||
return
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM recordings WHERE created_at < ?`, cutoff); err != nil {
|
||||
slog.Error("cleanup error", "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cleaned old recordings", "n", len(ids))
|
||||
for _, id := range ids {
|
||||
h.PublishEvent(map[string]any{
|
||||
"type": "event", "kind": "recording_deleted",
|
||||
"recording": map[string]any{"id": id},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DBSize returns the size of the database file in bytes, or 0 if it cannot
|
||||
// be stat'd.
|
||||
func DBSize(path string) int64 {
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
return fi.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package recording
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// PlayerCmd is a client→server playback control message.
|
||||
type PlayerCmd struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Ms int64 `json:"ms,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Player streams a single recording over a websocket with seek/pause/speed
|
||||
// controls. Frames are streamed from SQLite in a sliding window so memory use
|
||||
// is bounded.
|
||||
type Player struct {
|
||||
db *sql.DB
|
||||
conn *websocket.Conn
|
||||
recordingID int64
|
||||
totalFrames int
|
||||
durationMs int64
|
||||
dec *zstd.Decoder
|
||||
|
||||
mu sync.Mutex
|
||||
speed float64
|
||||
playing bool
|
||||
positionMs int64
|
||||
|
||||
cmdCh chan PlayerCmd
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewPlayer constructs a Player bound to a websocket connection.
|
||||
func NewPlayer(db *sql.DB, conn *websocket.Conn, id int64) (*Player, error) {
|
||||
dec, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Player{
|
||||
db: db,
|
||||
conn: conn,
|
||||
recordingID: id,
|
||||
dec: dec,
|
||||
speed: 1.0,
|
||||
playing: true,
|
||||
cmdCh: make(chan PlayerCmd, 16),
|
||||
stopCh: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Player) sendJSON(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.conn.WriteMessage(websocket.TextMessage, b)
|
||||
}
|
||||
|
||||
func (p *Player) sendErr(msg string) { _ = p.sendJSON(map[string]any{"error": msg}) }
|
||||
|
||||
func (p *Player) sendState() {
|
||||
p.mu.Lock()
|
||||
playing, speed, pos := p.playing, p.speed, p.positionMs
|
||||
p.mu.Unlock()
|
||||
_ = p.sendJSON(map[string]any{
|
||||
"type": "playback_state", "playing": playing, "speed": speed, "position_ms": pos,
|
||||
})
|
||||
}
|
||||
|
||||
// Run executes the playback loop. It returns when playback ends, the client
|
||||
// stops it, or the connection drops.
|
||||
func (p *Player) Run() {
|
||||
defer p.dec.Close()
|
||||
|
||||
var name string
|
||||
if err := p.db.QueryRow(`SELECT name FROM recordings WHERE id = ?`, p.recordingID).Scan(&name); err != nil {
|
||||
p.sendErr("recording not found")
|
||||
return
|
||||
}
|
||||
if err := p.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(offset_ms),0) FROM frames WHERE recording_id = ?`, p.recordingID).
|
||||
Scan(&p.totalFrames, &p.durationMs); err != nil {
|
||||
p.sendErr("failed to read recording")
|
||||
return
|
||||
}
|
||||
if p.totalFrames == 0 {
|
||||
p.sendErr("no frames in recording")
|
||||
return
|
||||
}
|
||||
|
||||
_ = p.sendJSON(map[string]any{
|
||||
"type": "playback_start", "name": name,
|
||||
"frames": p.totalFrames, "duration_ms": p.durationMs,
|
||||
})
|
||||
|
||||
go p.readCommands()
|
||||
|
||||
const prefetch = 256
|
||||
type frame struct {
|
||||
ms int64
|
||||
data []byte
|
||||
codec int
|
||||
}
|
||||
var buf []frame
|
||||
cursor := int64(-1)
|
||||
frameIdx := 0
|
||||
|
||||
refill := func(fromMs int64) error {
|
||||
buf = buf[:0]
|
||||
rows, err := p.db.Query(
|
||||
`SELECT offset_ms, data, codec FROM frames WHERE recording_id = ? AND offset_ms >= ? ORDER BY offset_ms LIMIT ?`,
|
||||
p.recordingID, fromMs, prefetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var f frame
|
||||
if err := rows.Scan(&f.ms, &f.data, &f.codec); err != nil {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
doSeek := func(targetMs int64) {
|
||||
if targetMs < 0 {
|
||||
targetMs = 0
|
||||
}
|
||||
if targetMs > p.durationMs {
|
||||
targetMs = p.durationMs
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.positionMs = targetMs
|
||||
p.mu.Unlock()
|
||||
cursor = targetMs - 1
|
||||
buf = buf[:0]
|
||||
var idx int
|
||||
if err := p.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM frames WHERE recording_id = ? AND offset_ms < ?`,
|
||||
p.recordingID, targetMs).Scan(&idx); err == nil {
|
||||
frameIdx = idx
|
||||
}
|
||||
p.sendState()
|
||||
}
|
||||
|
||||
if err := refill(0); err != nil {
|
||||
p.sendErr("read error")
|
||||
return
|
||||
}
|
||||
p.sendState()
|
||||
|
||||
anchorWall := time.Now()
|
||||
anchorPos := int64(0)
|
||||
|
||||
timer := time.NewTimer(time.Hour)
|
||||
defer timer.Stop()
|
||||
|
||||
armTimer := func() {
|
||||
p.mu.Lock()
|
||||
playing, speed, pos := p.playing, p.speed, p.positionMs
|
||||
p.mu.Unlock()
|
||||
|
||||
if !playing || len(buf) == 0 {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(time.Hour)
|
||||
return
|
||||
}
|
||||
next := buf[0]
|
||||
delta := time.Duration(float64(next.ms-pos)/speed) * time.Millisecond
|
||||
if delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(delta)
|
||||
}
|
||||
|
||||
armTimer()
|
||||
|
||||
stateBroadcast := time.NewTicker(250 * time.Millisecond)
|
||||
defer stateBroadcast.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
|
||||
case cmd := <-p.cmdCh:
|
||||
switch cmd.Cmd {
|
||||
case "play":
|
||||
p.mu.Lock()
|
||||
if !p.playing {
|
||||
p.playing = true
|
||||
anchorWall = time.Now()
|
||||
anchorPos = p.positionMs
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.sendState()
|
||||
armTimer()
|
||||
case "pause":
|
||||
p.mu.Lock()
|
||||
if p.playing {
|
||||
elapsed := time.Since(anchorWall)
|
||||
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
|
||||
if p.positionMs > p.durationMs {
|
||||
p.positionMs = p.durationMs
|
||||
}
|
||||
p.playing = false
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.sendState()
|
||||
armTimer()
|
||||
case "speed":
|
||||
p.mu.Lock()
|
||||
if cmd.Value > 0 && cmd.Value <= 16 {
|
||||
if p.playing {
|
||||
elapsed := time.Since(anchorWall)
|
||||
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
|
||||
}
|
||||
p.speed = cmd.Value
|
||||
anchorWall = time.Now()
|
||||
anchorPos = p.positionMs
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.sendState()
|
||||
armTimer()
|
||||
case "seek":
|
||||
doSeek(cmd.Ms)
|
||||
if err := refill(cmd.Ms); err != nil {
|
||||
p.sendErr("read error")
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
anchorWall = time.Now()
|
||||
anchorPos = p.positionMs
|
||||
p.mu.Unlock()
|
||||
armTimer()
|
||||
case "stop":
|
||||
return
|
||||
}
|
||||
|
||||
case <-stateBroadcast.C:
|
||||
p.mu.Lock()
|
||||
if p.playing {
|
||||
elapsed := time.Since(anchorWall)
|
||||
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
|
||||
if p.positionMs > p.durationMs {
|
||||
p.positionMs = p.durationMs
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.sendState()
|
||||
|
||||
case <-timer.C:
|
||||
p.mu.Lock()
|
||||
if !p.playing {
|
||||
p.mu.Unlock()
|
||||
armTimer()
|
||||
continue
|
||||
}
|
||||
elapsed := time.Since(anchorWall)
|
||||
pos := anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
|
||||
p.positionMs = pos
|
||||
p.mu.Unlock()
|
||||
|
||||
for len(buf) > 0 && buf[0].ms <= p.positionMs {
|
||||
f := buf[0]
|
||||
buf = buf[1:]
|
||||
data := f.data
|
||||
if f.codec == 1 {
|
||||
out, err := p.dec.DecodeAll(f.data, nil)
|
||||
if err != nil {
|
||||
slog.Warn("frame decode failed", "err", err)
|
||||
continue
|
||||
}
|
||||
data = out
|
||||
}
|
||||
frameIdx++
|
||||
msg := append([]byte(nil), `{"type":"playback_frame","offset_ms":`...)
|
||||
msg = strconv.AppendInt(msg, f.ms, 10)
|
||||
msg = append(msg, `,"frame":`...)
|
||||
msg = strconv.AppendInt(msg, int64(frameIdx), 10)
|
||||
msg = append(msg, `,"data":`...)
|
||||
msg = append(msg, data...)
|
||||
msg = append(msg, '}')
|
||||
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
cursor = f.ms
|
||||
}
|
||||
|
||||
if len(buf) == 0 {
|
||||
if err := refill(cursor + 1); err != nil {
|
||||
p.sendErr("read error")
|
||||
return
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
_ = p.sendJSON(map[string]any{"type": "playback_end"})
|
||||
return
|
||||
}
|
||||
}
|
||||
armTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) readCommands() {
|
||||
for {
|
||||
_, msg, err := p.conn.ReadMessage()
|
||||
if err != nil {
|
||||
close(p.stopCh)
|
||||
return
|
||||
}
|
||||
var cmd PlayerCmd
|
||||
if err := json.Unmarshal(msg, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case p.cmdCh <- cmd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package recording
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
|
||||
)
|
||||
|
||||
const (
|
||||
flushBatchSize = 64
|
||||
flushInterval = 100 * time.Millisecond
|
||||
framesChanCap = 1024
|
||||
idleTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type rawFrame struct {
|
||||
t time.Time
|
||||
data []byte
|
||||
}
|
||||
|
||||
// Recorder receives raw UDP frames and persists them in batched, optionally
|
||||
// zstd-compressed inserts to SQLite. A single writer goroutine owns the DB
|
||||
// handles so there is no contention on the UDP hot path.
|
||||
type Recorder struct {
|
||||
db *sql.DB
|
||||
hub *hub.Hub
|
||||
frames chan rawFrame
|
||||
compress bool
|
||||
enc *zstd.Encoder
|
||||
|
||||
// session bookkeeping (writer-goroutine-owned)
|
||||
sessionID int64
|
||||
sessionName string
|
||||
startTime time.Time
|
||||
frameCount int
|
||||
lastFrameAt time.Time
|
||||
|
||||
insertFrame *sql.Stmt
|
||||
insertSession *sql.Stmt
|
||||
updateSession *sql.Stmt
|
||||
|
||||
doneCh chan struct{}
|
||||
stoppedCh chan struct{}
|
||||
}
|
||||
|
||||
// NewRecorder constructs a Recorder. Call Start to launch the writer goroutine.
|
||||
func NewRecorder(db *sql.DB, h *hub.Hub, compress bool) (*Recorder, error) {
|
||||
r := &Recorder{
|
||||
db: db,
|
||||
hub: h,
|
||||
frames: make(chan rawFrame, framesChanCap),
|
||||
compress: compress,
|
||||
doneCh: make(chan struct{}),
|
||||
stoppedCh: make(chan struct{}),
|
||||
}
|
||||
var err error
|
||||
if r.insertFrame, err = db.Prepare(`INSERT INTO frames(recording_id, offset_ms, data, codec) VALUES(?,?,?,?)`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.insertSession, err = db.Prepare(`INSERT INTO recordings(name) VALUES(?)`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.updateSession, err = db.Prepare(`UPDATE recordings SET frames=?, duration_ms=? WHERE id=?`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if compress {
|
||||
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.enc = enc
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Submit hands a UDP packet to the recorder. Non-blocking: if the queue is
|
||||
// full the frame is dropped and counted in stats.FramesDropped.
|
||||
func (r *Recorder) Submit(data []byte) {
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
select {
|
||||
case r.frames <- rawFrame{t: time.Now(), data: cp}:
|
||||
default:
|
||||
stats.Global.FramesDropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the writer goroutine. Stop must be called to flush.
|
||||
func (r *Recorder) Start() { go r.run() }
|
||||
|
||||
// Stop signals the writer to drain the queue and exit, then waits for it.
|
||||
func (r *Recorder) Stop() {
|
||||
close(r.doneCh)
|
||||
<-r.stoppedCh
|
||||
}
|
||||
|
||||
func (r *Recorder) run() {
|
||||
defer close(r.stoppedCh)
|
||||
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
idleCheck := time.NewTicker(idleTimeout / 2)
|
||||
defer idleCheck.Stop()
|
||||
|
||||
batch := make([]rawFrame, 0, flushBatchSize)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
if err := r.writeBatch(batch); err != nil {
|
||||
slog.Error("flush batch failed", "err", err, "n", len(batch))
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.doneCh:
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case f := <-r.frames:
|
||||
batch = append(batch, f)
|
||||
if len(batch) >= flushBatchSize {
|
||||
flush()
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
flush()
|
||||
r.finalizeSession()
|
||||
return
|
||||
|
||||
case f := <-r.frames:
|
||||
batch = append(batch, f)
|
||||
if len(batch) >= flushBatchSize {
|
||||
flush()
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
|
||||
case <-idleCheck.C:
|
||||
if r.sessionID != 0 && time.Since(r.lastFrameAt) > idleTimeout {
|
||||
flush()
|
||||
r.finalizeSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeBatch persists frames within a single transaction. If no session is
|
||||
// active, one is started using the first frame's timestamp.
|
||||
func (r *Recorder) writeBatch(batch []rawFrame) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.sessionID == 0 {
|
||||
first := batch[0].t
|
||||
r.sessionName = first.Format("2006-01-02_15-04-05")
|
||||
res, err := tx.Stmt(r.insertSession).Exec(r.sessionName)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
r.sessionID, _ = res.LastInsertId()
|
||||
r.startTime = first
|
||||
r.frameCount = 0
|
||||
slog.Info("recording started", "name", r.sessionName, "id", r.sessionID)
|
||||
r.hub.PublishEvent(map[string]any{
|
||||
"type": "event", "kind": "recording_started",
|
||||
"recording": map[string]any{"id": r.sessionID, "name": r.sessionName},
|
||||
})
|
||||
}
|
||||
|
||||
stmt := tx.Stmt(r.insertFrame)
|
||||
for _, f := range batch {
|
||||
payload := f.data
|
||||
codec := 0
|
||||
stats.Global.BytesBeforeCompr.Add(int64(len(payload)))
|
||||
if r.compress && r.enc != nil {
|
||||
payload = r.enc.EncodeAll(f.data, make([]byte, 0, len(f.data)/2+64))
|
||||
codec = 1
|
||||
}
|
||||
stats.Global.BytesAfterCompr.Add(int64(len(payload)))
|
||||
offset := f.t.Sub(r.startTime).Milliseconds()
|
||||
if _, err := stmt.Exec(r.sessionID, offset, payload, codec); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
r.frameCount++
|
||||
r.lastFrameAt = f.t
|
||||
stats.Global.FramesWritten.Add(1)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Recorder) finalizeSession() {
|
||||
if r.sessionID == 0 {
|
||||
return
|
||||
}
|
||||
duration := r.lastFrameAt.Sub(r.startTime).Milliseconds()
|
||||
if _, err := r.updateSession.Exec(r.frameCount, duration, r.sessionID); err != nil {
|
||||
slog.Error("finalize session failed", "err", err)
|
||||
}
|
||||
slog.Info("recording stopped", "name", r.sessionName, "frames", r.frameCount, "duration_ms", duration)
|
||||
r.hub.PublishEvent(map[string]any{
|
||||
"type": "event", "kind": "recording_stopped",
|
||||
"recording": map[string]any{
|
||||
"id": r.sessionID, "name": r.sessionName,
|
||||
"frames": r.frameCount, "duration_ms": duration,
|
||||
},
|
||||
})
|
||||
r.sessionID = 0
|
||||
r.sessionName = ""
|
||||
r.frameCount = 0
|
||||
}
|
||||
|
||||
// CurrentSession returns a snapshot of the active recording, or zero values
|
||||
// if none. Reads are not strictly synchronized; slight staleness is acceptable
|
||||
// for the /api/stats endpoint.
|
||||
func (r *Recorder) CurrentSession() (id int64, name string, frames int, elapsedMs int64) {
|
||||
id = r.sessionID
|
||||
name = r.sessionName
|
||||
frames = r.frameCount
|
||||
if id != 0 {
|
||||
elapsedMs = time.Since(r.startTime).Milliseconds()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package server wires HTTP/WS routes and the UDP listener to the rest of
|
||||
// the application. It owns the Fiber app and the autocert setup.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a Fiber handler enforcing HTTP Basic Auth with the
|
||||
// given password. WebSocket upgrades skip auth because browsers cannot
|
||||
// reliably supply Authorization headers on WS; the page itself is gated.
|
||||
func AuthMiddleware(password string) fiber.Handler {
|
||||
expected := "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+password))
|
||||
expectedBytes := []byte(expected)
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
deny := func() error {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized")
|
||||
}
|
||||
|
||||
auth := c.Get("Authorization")
|
||||
if auth == "" {
|
||||
return deny()
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(auth), expectedBytes) == 1 {
|
||||
return c.Next()
|
||||
}
|
||||
const scheme = "Basic "
|
||||
if len(auth) < len(scheme) || auth[:len(scheme)] != scheme {
|
||||
return deny()
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth[len(scheme):])
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(auth[len(scheme):])
|
||||
if err != nil {
|
||||
return deny()
|
||||
}
|
||||
}
|
||||
creds := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(creds) != 2 {
|
||||
return deny()
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(creds[1]), []byte(password)) != 1 {
|
||||
return deny()
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ListenUDP reads UDP packets and submits them to the recorder and hub.
|
||||
// It exits on ctx cancellation.
|
||||
func ListenUDP(ctx context.Context, addr string, h *hub.Hub, rec *recording.Recorder) {
|
||||
conn, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
slog.Error("udp listen failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("udp listening", "addr", addr)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("udp read error", "err", err)
|
||||
continue
|
||||
}
|
||||
stats.Global.UDPPackets.Add(1)
|
||||
stats.Global.UDPBytes.Add(int64(n))
|
||||
stats.Global.LastUDPPacketUnix.Store(time.Now().UnixNano())
|
||||
|
||||
data := buf[:n]
|
||||
rec.Submit(data)
|
||||
|
||||
if h.Count() > 0 {
|
||||
cp := make([]byte, n)
|
||||
copy(cp, data)
|
||||
h.Broadcast(cp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
nethttp "net/http"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// Listen starts serving the app on either HTTPS w/ autocert (if domain is set)
|
||||
// or plain HTTP. It returns a channel that receives any startup error.
|
||||
//
|
||||
// httpAddr is used both as the plain HTTP listener and as the ACME challenge
|
||||
// listener when domain is set. tlsAddr is only used when domain is set.
|
||||
func Listen(app *fiber.App, httpAddr, tlsAddr, domain, certDir string) <-chan error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
if domain == "" {
|
||||
slog.Info("no domain set; HTTP only", "addr", httpAddr)
|
||||
go func() {
|
||||
if err := app.Listen(httpAddr); err != nil {
|
||||
errCh <- fmt.Errorf("http: %w", err)
|
||||
}
|
||||
}()
|
||||
return errCh
|
||||
}
|
||||
|
||||
cm := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(domain),
|
||||
Cache: autocert.DirCache(certDir),
|
||||
}
|
||||
|
||||
if httpAddr != "" {
|
||||
go func() {
|
||||
slog.Info("starting HTTP server (ACME + redirect)", "addr", httpAddr)
|
||||
srv := &nethttp.Server{Addr: httpAddr, Handler: cm.HTTPHandler(nil)}
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, nethttp.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("http: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("starting HTTPS server with autocert", "addr", tlsAddr, "domain", domain)
|
||||
ln, err := tls.Listen("tcp", tlsAddr, &tls.Config{GetCertificate: cm.GetCertificate})
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("tls listen: %w", err)
|
||||
return errCh
|
||||
}
|
||||
go func() {
|
||||
if err := app.Listener(ln); err != nil {
|
||||
errCh <- fmt.Errorf("https: %w", err)
|
||||
}
|
||||
}()
|
||||
return errCh
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"log/slog"
|
||||
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/assets"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording"
|
||||
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
|
||||
)
|
||||
|
||||
// Options configures the Fiber app constructed by New.
|
||||
type Options struct {
|
||||
Password string
|
||||
DBPath string
|
||||
Verbose bool
|
||||
Compress bool
|
||||
Retention time.Duration
|
||||
DB *sql.DB
|
||||
Hub *hub.Hub
|
||||
Recorder *recording.Recorder
|
||||
}
|
||||
|
||||
// New constructs the Fiber application with all middleware and routes wired.
|
||||
func New(opt Options) *fiber.App {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${method} ${path} ${status}\n",
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
if opt.Verbose {
|
||||
return false
|
||||
}
|
||||
// Suppress noisy WS access logs by default.
|
||||
path := c.Path()
|
||||
return len(path) >= 3 && path[:3] == "/ws"
|
||||
},
|
||||
}))
|
||||
|
||||
app.Use(AuthMiddleware(opt.Password))
|
||||
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
data, err := assets.FS.ReadFile("viewer.html")
|
||||
if err != nil {
|
||||
return fiber.ErrInternalServerError
|
||||
}
|
||||
c.Set("Content-Type", "text/html; charset=utf-8")
|
||||
return c.Send(data)
|
||||
})
|
||||
|
||||
// Live WebSocket.
|
||||
app.Use("/ws", upgradeOnly())
|
||||
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
|
||||
cl := opt.Hub.Add(c)
|
||||
slog.Info("client connected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count())
|
||||
defer func() {
|
||||
opt.Hub.Remove(cl)
|
||||
slog.Info("client disconnected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count())
|
||||
}()
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Recording API.
|
||||
app.Get("/api/recordings", func(c *fiber.Ctx) error {
|
||||
since := c.Query("since", "")
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "0"))
|
||||
recs, err := recording.List(opt.DB, since, limit)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(recs)
|
||||
})
|
||||
|
||||
app.Delete("/api/recordings/:id", func(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "invalid id"})
|
||||
}
|
||||
n, err := recording.Delete(opt.DB, id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if n == 0 {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "not found"})
|
||||
}
|
||||
opt.Hub.PublishEvent(map[string]any{
|
||||
"type": "event", "kind": "recording_deleted",
|
||||
"recording": map[string]any{"id": id},
|
||||
})
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
})
|
||||
|
||||
app.Get("/api/stats", statsHandler(opt))
|
||||
|
||||
// Playback websocket.
|
||||
app.Use("/ws/playback", upgradeOnly())
|
||||
app.Get("/ws/playback", websocket.New(func(c *websocket.Conn) {
|
||||
idStr := c.Query("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
_ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid id"}`))
|
||||
return
|
||||
}
|
||||
slog.Info("playback start", "id", id, "addr", c.RemoteAddr().String())
|
||||
p, err := recording.NewPlayer(opt.DB, c, id)
|
||||
if err != nil {
|
||||
_ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"player init failed"}`))
|
||||
return
|
||||
}
|
||||
p.Run()
|
||||
slog.Info("playback end", "id", id, "addr", c.RemoteAddr().String())
|
||||
}))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func upgradeOnly() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
return c.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
}
|
||||
}
|
||||
|
||||
func statsHandler(opt Options) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
dbSize := recording.DBSize(opt.DBPath)
|
||||
lastUDP := stats.Global.LastUDPPacketUnix.Load()
|
||||
var lastAgeMs int64 = -1
|
||||
if lastUDP > 0 {
|
||||
lastAgeMs = (time.Now().UnixNano() - lastUDP) / int64(time.Millisecond)
|
||||
}
|
||||
before := stats.Global.BytesBeforeCompr.Load()
|
||||
after := stats.Global.BytesAfterCompr.Load()
|
||||
var compressionRatio float64
|
||||
if after > 0 {
|
||||
compressionRatio = float64(before) / float64(after)
|
||||
}
|
||||
curID, curName, curFrames, curElapsed := opt.Recorder.CurrentSession()
|
||||
var current any
|
||||
if curID != 0 {
|
||||
current = map[string]any{
|
||||
"id": curID, "name": curName,
|
||||
"frames": curFrames, "elapsed_ms": curElapsed,
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"uptime_s": int64(time.Since(stats.Global.StartTime).Seconds()),
|
||||
"udp_packets_total": stats.Global.UDPPackets.Load(),
|
||||
"udp_bytes_total": stats.Global.UDPBytes.Load(),
|
||||
"udp_last_packet_age_ms": lastAgeMs,
|
||||
"ws_clients": opt.Hub.Count(),
|
||||
"frames_written_total": stats.Global.FramesWritten.Load(),
|
||||
"frames_dropped_total": stats.Global.FramesDropped.Load(),
|
||||
"bytes_before_compr": before,
|
||||
"bytes_after_compr": after,
|
||||
"compression_ratio": compressionRatio,
|
||||
"db_size_bytes": dbSize,
|
||||
"current_recording": current,
|
||||
"retention_hours": opt.Retention.Hours(),
|
||||
"compression_enabled": opt.Compress,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package stats holds process-wide observability counters shared across packages.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stats are process-wide atomic counters. A single global instance is used so
|
||||
// any package can record events without dependency injection plumbing.
|
||||
type Stats struct {
|
||||
StartTime time.Time
|
||||
UDPPackets atomic.Int64
|
||||
UDPBytes atomic.Int64
|
||||
FramesWritten atomic.Int64
|
||||
FramesDropped atomic.Int64
|
||||
BytesBeforeCompr atomic.Int64
|
||||
BytesAfterCompr atomic.Int64
|
||||
LastUDPPacketUnix atomic.Int64 // unix nanos; 0 = never
|
||||
}
|
||||
|
||||
// Global is the singleton used by all packages.
|
||||
var Global = &Stats{StartTime: time.Now()}
|
||||
Reference in New Issue
Block a user