Skip to content
Draft
1 change: 1 addition & 0 deletions docker-compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ services:
- LOG_LEVEL
- ENABLE_USER_REGISTRATION
- GITHUB_TOKEN
- MAX_CONCURRENT_RENDERS=${MAX_CONCURRENT_RENDERS:-5}

volumes:
go_modules:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.https.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
- PRODUCTION
- ENABLE_USER_REGISTRATION
- GITHUB_TOKEN
- MAX_CONCURRENT_RENDERS=${MAX_CONCURRENT_RENDERS:-5}

healthcheck:
test: ["CMD", "/app/tronbyt-server", "health"]
Expand Down
1 change: 1 addition & 0 deletions docker-compose.postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- ENABLE_USER_REGISTRATION
- SINGLE_USER_AUTO_LOGIN
- GITHUB_TOKEN
- MAX_CONCURRENT_RENDERS=${MAX_CONCURRENT_RENDERS:-5}
- DB_DSN=host=db user=tronbyt password=tronbyt dbname=tronbyt port=5432 sslmode=disable TimeZone=UTC
depends_on:
- db
Expand Down
1 change: 1 addition & 0 deletions docker-compose.redis.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ services:
- ENABLE_USER_REGISTRATION
- SINGLE_USER_AUTO_LOGIN
- GITHUB_TOKEN
- MAX_CONCURRENT_RENDERS=${MAX_CONCURRENT_RENDERS:-5}

healthcheck:
test: ["CMD", "/app/tronbyt-server", "health"]
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- ENABLE_USER_REGISTRATION
- SINGLE_USER_AUTO_LOGIN
- GITHUB_TOKEN
- MAX_CONCURRENT_RENDERS=${MAX_CONCURRENT_RENDERS:-5}

healthcheck:
test: ["CMD", "/app/tronbyt-server", "health"]
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Settings struct {
TrustedProxies string `env:"TRONBYT_TRUSTED_PROXIES" envDefault:"*"`
LogLevel string `env:"LOG_LEVEL" envDefault:"INFO"`
EnableUpdateChecks bool `env:"ENABLE_UPDATE_CHECKS" envDefault:"true"`
MaxConcurrentRenders int `env:"MAX_CONCURRENT_RENDERS" envDefault:"5"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's good to provide a default value for MaxConcurrentRenders, but consider adding a comment explaining why 5 was chosen as the default. Is it based on testing or hardware constraints? This will help future maintainers understand the rationale behind the default value.

Also, consider adding a validation to ensure that the value is not negative, and log a warning if it is.

}

// TemplateConfig holds configuration values needed in templates.
Expand Down
17 changes: 17 additions & 0 deletions internal/server/handlers_device_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ import (
func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")

renderMetrics.RecordRequest()

// Acquire per-device semaphore to prevent queue backup from same device.
// If already processing a request for this device, return cached image immediately.
if !s.acquireDeviceSemaphore(id) {
slog.Debug("Device busy, serving cached image", "device", id)
s.serveCachedImageForDevice(w, r, id)
return
}
defer s.releaseDeviceSemaphore(id)

var device *data.Device
if d, err := DeviceFromContext(r.Context()); err == nil {
device = d
Expand Down Expand Up @@ -107,9 +118,13 @@ func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
// Send default image if error (or not found)
slog.Error("Failed to get next app image", "device", device.ID, "error", err)
s.sendDefaultImage(w, r, device)
webpMetrics.RecordWebPServed(0)
webpMetrics.RecordUniqueDevice(device.ID)
Comment on lines +121 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It seems like webpMetrics.RecordWebPServed(0) and webpMetrics.RecordUniqueDevice(device.ID) are called when there is an error. It might be useful to log the error message along with these metrics to provide more context for debugging.

return
}

webpMetrics.RecordUniqueDevice(device.ID)

// For HTTP devices, we assume "Sent" equals "Displaying" (or roughly so).
// We update DisplayingApp here so the Preview uses the explicit field instead of fallback.
if app != nil {
Expand Down Expand Up @@ -142,6 +157,8 @@ func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
dwell := device.GetEffectiveDwellTime(app)
w.Header().Set("Tronbyt-Dwell-Secs", fmt.Sprintf("%d", dwell))

webpMetrics.RecordWebPServed(len(imgData))

if _, err := w.Write(imgData); err != nil {
slog.Error("Failed to write image data to response", "error", err)
// Log error, but can't change HTTP status after writing headers.
Expand Down
21 changes: 21 additions & 0 deletions internal/server/handlers_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,24 @@ func (s *Server) handleRefreshSystemRepo(w http.ResponseWriter, r *http.Request)

http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
}

func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
user := GetUser(r)
if !user.IsAdmin {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

var totalDevices, totalUsers int64
s.DB.Model(&data.Device{}).Count(&totalDevices)
s.DB.Model(&data.User{}).Count(&totalUsers)

stats := GetStatsSnapshot()

s.renderTemplate(w, r, "admin_dashboard", TemplateData{
User: user,
TotalDevices: totalDevices,
TotalUsers: totalUsers,
Stats: stats,
})
}
5 changes: 5 additions & 0 deletions internal/server/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ type TemplateData struct {
AppConfig map[string]any
AppMetadata *apps.AppMetadata

// Admin Dashboard
TotalDevices int64
TotalUsers int64
Stats StatsSnapshot

// Device Update Extras
ColorFilterOptions []ColorFilterOption
ShowFullAnimationOptions []ShowFullAnimationOption
Expand Down
267 changes: 267 additions & 0 deletions internal/server/render_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
package server

import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)

type RenderMetrics struct {
activeCount atomic.Int64
queuedCount atomic.Int64
totalCount atomic.Int64
failedCount atomic.Int64
totalDur int64 // nanoseconds
maxDur atomic.Int64

// Sliding window tracking (timestamps of events in last 60 seconds)
mu sync.Mutex
rendersByMinute []int64 // timestamps of renders
reqsByMinute []int64 // timestamps of requests
}

var renderMetrics RenderMetrics

type WebPMetrics struct {
servedCount atomic.Int64
renderCount atomic.Int64
bytesServed atomic.Int64
uniqueMu sync.Mutex
uniqueDevices map[string]int64 // device ID -> last seen timestamp

// Sliding window tracking
mu sync.Mutex
webpsByMinute []int64 // timestamps of webp serves
}

var webpMetrics WebPMetrics

const windowDuration = 60 * time.Second

func (m *RenderMetrics) StartRender() {
m.activeCount.Add(1)
m.queuedCount.Add(1)
}

func (m *RenderMetrics) EndRender(dur time.Duration, failed bool) {
m.activeCount.Add(-1)
m.queuedCount.Add(-1)
m.totalCount.Add(1)
atomic.AddInt64(&m.totalDur, int64(dur))

currentMax := m.maxDur.Load()
if int64(dur) > currentMax {
m.maxDur.Store(int64(dur))
}

if failed {
m.failedCount.Add(1)
}

now := time.Now().Unix()
m.mu.Lock()
m.rendersByMinute = append(m.rendersByMinute, now)
m.mu.Unlock()
}

func (m *RenderMetrics) RecordRequest() {
now := time.Now().Unix()
m.mu.Lock()
m.reqsByMinute = append(m.reqsByMinute, now)
m.mu.Unlock()
}

func (m *RenderMetrics) ActiveCount() int64 {
return m.activeCount.Load()
}

func (m *RenderMetrics) AvgDuration() time.Duration {
total := m.totalCount.Load()
if total == 0 {
return 0
}
return time.Duration(m.totalDur / total)
}

func (m *RenderMetrics) MaxDuration() time.Duration {
return time.Duration(m.maxDur.Load())
}

func (m *RenderMetrics) TotalCount() int64 {
return m.totalCount.Load()
}

func (m *RenderMetrics) FailedCount() int64 {
return m.failedCount.Load()
}

func (m *RenderMetrics) QueuedCount() int64 {
return m.queuedCount.Load()
}

func (m *RenderMetrics) RendersPerMin() int64 {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().Add(-windowDuration).Unix()
var count int64
for _, t := range m.rendersByMinute {
if t >= cutoff {
count++
}
}
return count
}

func (m *RenderMetrics) ReqsPerMin() int64 {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().Add(-windowDuration).Unix()
var count int64
for _, t := range m.reqsByMinute {
if t >= cutoff {
count++
}
}
return count
}

func (w *WebPMetrics) RecordWebPServed(bytes int) {
w.servedCount.Add(1)
w.bytesServed.Add(int64(bytes))

now := time.Now().Unix()
w.mu.Lock()
w.webpsByMinute = append(w.webpsByMinute, now)
w.mu.Unlock()
}

func (w *WebPMetrics) RecordRender() {
w.renderCount.Add(1)
}

func (w *WebPMetrics) RecordUniqueDevice(deviceID string) {
now := time.Now().Unix()
w.uniqueMu.Lock()
if w.uniqueDevices == nil {
w.uniqueDevices = make(map[string]int64)
}
w.uniqueDevices[deviceID] = now
w.uniqueMu.Unlock()
}

func (w *WebPMetrics) LogStats() {
served := w.servedCount.Swap(0)
renders := w.renderCount.Swap(0)

cutoff := time.Now().Add(-windowDuration).Unix()
w.uniqueMu.Lock()
var uniqueDevs int64
for _, lastSeen := range w.uniqueDevices {
if lastSeen >= cutoff {
uniqueDevs++
}
}
// Clean up old entries
for id, lastSeen := range w.uniqueDevices {
if lastSeen < cutoff {
delete(w.uniqueDevices, id)
}
}
w.uniqueMu.Unlock()

loadAvg1m := getLoadAverage()
if served > 0 {
slog.Info(fmt.Sprintf("Stats ------ : %.1f - %d / %d ", loadAvg1m, served, renders))
Comment on lines +177 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider adding a unit test for getLoadAverage() to ensure it returns a valid value, or handles errors gracefully. This is important because the function reads from a file, which could fail.

}
}

func getLoadAverage() float64 {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0
}
parts := strings.Split(string(data), " ")
if len(parts) < 1 {
return 0
}
f, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0
}
return f
}

func (w *WebPMetrics) ServedCount() int64 {
return w.servedCount.Load()
}

func (w *WebPMetrics) RenderCount() int64 {
return w.renderCount.Load()
}

func (w *WebPMetrics) BytesServed() int64 {
return w.bytesServed.Load()
}

func (w *WebPMetrics) WebpsPerMin() int64 {
w.mu.Lock()
defer w.mu.Unlock()
cutoff := time.Now().Add(-windowDuration).Unix()
var count int64
for _, t := range w.webpsByMinute {
if t >= cutoff {
count++
}
}
return count
}

func (w *WebPMetrics) UniqueDevicesPerMin() int64 {
cutoff := time.Now().Add(-windowDuration).Unix()
w.uniqueMu.Lock()
defer w.uniqueMu.Unlock()
var count int64
for _, lastSeen := range w.uniqueDevices {
if lastSeen >= cutoff {
count++
}
}
return count
}

type StatsSnapshot struct {
ActiveRenders int64
QueuedRenders int64
TotalRenders int64
FailedRenders int64
AvgRenderMs int64
MaxRenderMs int64
RendersPerMin int64
ReqsPerMin int64
WebpsServed int64
WebpsPerMin int64
BytesServedMB float64
UniqueDevsPerMin int64
}

func GetStatsSnapshot() StatsSnapshot {
return StatsSnapshot{
ActiveRenders: renderMetrics.ActiveCount(),
QueuedRenders: renderMetrics.QueuedCount(),
TotalRenders: renderMetrics.TotalCount(),
FailedRenders: renderMetrics.FailedCount(),
AvgRenderMs: renderMetrics.AvgDuration().Milliseconds(),
MaxRenderMs: renderMetrics.MaxDuration().Milliseconds(),
RendersPerMin: renderMetrics.RendersPerMin(),
ReqsPerMin: renderMetrics.ReqsPerMin(),
WebpsServed: webpMetrics.ServedCount(),
WebpsPerMin: webpMetrics.WebpsPerMin(),
BytesServedMB: float64(webpMetrics.BytesServed()) / (1024 * 1024),
UniqueDevsPerMin: webpMetrics.UniqueDevicesPerMin(),
}
}
Loading
Loading