177 lines
5.1 KiB
Go
177 lines
5.1 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|