You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2015 lines
61 KiB
2015 lines
61 KiB
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"embed"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"log/slog"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"track-gopher/db"
|
|
"track-gopher/derby"
|
|
"track-gopher/models"
|
|
"track-gopher/web/templates"
|
|
)
|
|
|
|
//go:embed static
|
|
var content embed.FS
|
|
|
|
// Server represents the web server for the derby clock
|
|
type Server struct {
|
|
router *chi.Mux
|
|
clock *derby.DerbyClock
|
|
events <-chan derby.Event
|
|
clients map[chan string]bool
|
|
clientsMux sync.Mutex
|
|
adminclients map[chan string]bool
|
|
adminclientsMux sync.Mutex
|
|
port int
|
|
server *http.Server
|
|
shutdown chan struct{}
|
|
logger *slog.Logger
|
|
db *db.DB
|
|
adminEvents chan string
|
|
useHTTP2 bool
|
|
}
|
|
|
|
// NewServer creates a new web server
|
|
func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, useHTTP2 bool, logger *slog.Logger) (*Server, error) {
|
|
// Initialize database
|
|
database, err := db.New(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
|
}
|
|
|
|
// Create server
|
|
s := &Server{
|
|
router: chi.NewRouter(),
|
|
clock: clock,
|
|
events: events,
|
|
clients: make(map[chan string]bool),
|
|
clientsMux: sync.Mutex{},
|
|
adminclients: make(map[chan string]bool),
|
|
adminclientsMux: sync.Mutex{},
|
|
port: port,
|
|
shutdown: make(chan struct{}),
|
|
logger: logger,
|
|
db: database,
|
|
adminEvents: make(chan string, 10),
|
|
useHTTP2: useHTTP2,
|
|
}
|
|
|
|
// Set up routes
|
|
s.routes()
|
|
|
|
// Start event forwarder
|
|
go s.forwardEvents()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// routes sets up the routes for the server
|
|
func (s *Server) routes() {
|
|
// Middleware
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(middleware.RealIP)
|
|
s.router.Use(middleware.Logger)
|
|
s.router.Use(middleware.Recoverer)
|
|
|
|
// Custom middleware to log all requests
|
|
s.router.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestID := middleware.GetReqID(r.Context())
|
|
s.logger.Debug("Request started",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"requestID", requestID,
|
|
"remoteAddr", r.RemoteAddr)
|
|
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
|
|
s.logger.Debug("Request completed",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"requestID", requestID,
|
|
"duration", time.Since(start))
|
|
})
|
|
})
|
|
|
|
// Use a very long timeout for SSE endpoints, shorter for others
|
|
s.router.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/api/events") || strings.Contains(r.URL.Path, "/api/admin/events") {
|
|
// For SSE endpoints, use a context with a very long timeout
|
|
ctx, cancel := context.WithTimeout(r.Context(), 24*time.Hour)
|
|
defer cancel()
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
} else {
|
|
// For regular endpoints, use a shorter timeout
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
}
|
|
})
|
|
})
|
|
|
|
// Add middleware to set appropriate headers for SSE
|
|
s.router.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/api/events") || strings.Contains(r.URL.Path, "/api/admin/events") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
s.logger.Debug("SSE headers set", "path", r.URL.Path)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
|
|
// Create a file server for static files
|
|
staticFS, err := fs.Sub(content, "static")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Set up static file server with proper MIME types
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
s.router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) {
|
|
// Set correct MIME types based on file extension
|
|
path := r.URL.Path
|
|
if strings.HasSuffix(path, ".js") {
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
} else if strings.HasSuffix(path, ".css") {
|
|
w.Header().Set("Content-Type", "text/css")
|
|
}
|
|
|
|
// The key fix: properly handle the path
|
|
// Remove the /static prefix for the filesystem lookup
|
|
pathWithoutPrefix := strings.TrimPrefix(r.URL.Path, "/static")
|
|
|
|
// Create a new request with the modified path
|
|
r2 := new(http.Request)
|
|
*r2 = *r
|
|
r2.URL = new(url.URL)
|
|
*r2.URL = *r.URL
|
|
r2.URL.Path = pathWithoutPrefix
|
|
|
|
fileServer.ServeHTTP(w, r2)
|
|
})
|
|
|
|
// API routes
|
|
s.router.Route("/api", func(r chi.Router) {
|
|
r.Post("/reset", s.handleReset())
|
|
r.Post("/force-end", s.handleForceEnd())
|
|
r.Get("/events", s.handleEvents())
|
|
r.Get("/validate/car-number", s.handleValidateCarNumber())
|
|
r.Route("/groups", func(r chi.Router) {
|
|
r.Post("/", s.handleCreateGroup())
|
|
r.Put("/{id}", s.handleUpdateGroup())
|
|
r.Delete("/{id}", s.handleDeleteGroup())
|
|
})
|
|
r.Route("/racers", func(r chi.Router) {
|
|
r.Post("/", s.handleCreateRacer())
|
|
r.Put("/{id}", s.handleUpdateRacer())
|
|
r.Delete("/{id}", s.handleDeleteRacer())
|
|
})
|
|
r.Route("/heats", func(r chi.Router) {
|
|
r.Post("/generate", s.handleGenerateHeats())
|
|
})
|
|
r.Route("/race", func(r chi.Router) {
|
|
r.Get("/current-heat", s.handleCurrentHeat())
|
|
r.Post("/next-heat", s.handleNextHeat())
|
|
r.Post("/previous-heat", s.handlePreviousHeat())
|
|
r.Post("/rerun-heat", s.handleRerunHeat())
|
|
r.Post("/set-group", s.handleSetRacingGroup())
|
|
})
|
|
r.Route("/admin", func(r chi.Router) {
|
|
r.Get("/events", s.handleAdminEvents())
|
|
})
|
|
r.Post("/results/reveal", s.handleRevealNextResult())
|
|
})
|
|
|
|
s.router.Get("/admin", s.handleAdmin())
|
|
s.router.Get("/register", s.handleRegister())
|
|
s.router.Get("/register/form", s.handleRegisterForm())
|
|
|
|
// Add heats page route
|
|
s.router.Get("/heats", s.handleHeats())
|
|
s.router.Get("/heats-content", s.handleHeatsContent())
|
|
|
|
// Add racers list route
|
|
s.router.Get("/admin/racers/list", s.handleRacersList())
|
|
|
|
// Add race manager routes
|
|
s.router.Get("/race", s.handleRacePublic())
|
|
s.router.Get("/race/manage", s.handleRaceManage())
|
|
|
|
// Add final results route
|
|
s.router.Get("/results", s.handleFinalResults())
|
|
|
|
// Add public final results route
|
|
s.router.Get("/results/public", s.handleFinalResultsPublic())
|
|
|
|
// Main page
|
|
s.router.Get("/", s.handleIndex())
|
|
|
|
}
|
|
|
|
// Start starts the web server with HTTP/2 support
|
|
func (s *Server) Start() error {
|
|
addr := fmt.Sprintf(":%d", s.port)
|
|
|
|
// Configure TLS
|
|
tlsConfig := &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
PreferServerCipherSuites: true,
|
|
CipherSuites: []uint16{
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
},
|
|
NextProtos: []string{"h2", "http/1.1"},
|
|
}
|
|
|
|
s.server = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.router,
|
|
TLSConfig: tlsConfig,
|
|
ReadTimeout: 120 * time.Second,
|
|
WriteTimeout: 120 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Start server in a goroutine so we can return from this function
|
|
go func() {
|
|
s.logger.Info("Starting web server", "port", s.port, "http2", s.useHTTP2)
|
|
|
|
var err error
|
|
if s.useHTTP2 {
|
|
// Check if certificate files exist
|
|
certFile := "server.crt"
|
|
keyFile := "server.key"
|
|
|
|
certExists := fileExists(certFile)
|
|
keyExists := fileExists(keyFile)
|
|
|
|
if certExists && keyExists {
|
|
// Start HTTPS server with HTTP/2 support
|
|
s.logger.Info("Using TLS with HTTP/2", "certFile", certFile, "keyFile", keyFile)
|
|
err = s.server.ListenAndServeTLS(certFile, keyFile)
|
|
} else {
|
|
// Generate self-signed certificate for development
|
|
s.logger.Info("Generating self-signed certificate for HTTP/2")
|
|
cert, key, genErr := generateSelfSignedCert()
|
|
if genErr != nil {
|
|
s.logger.Error("Failed to generate self-signed certificate", "error", genErr)
|
|
|
|
// Fall back to HTTP/1.1
|
|
s.logger.Info("Falling back to HTTP/1.1 (no TLS)")
|
|
s.server.TLSConfig = nil
|
|
err = s.server.ListenAndServe()
|
|
} else {
|
|
// Write certificate files
|
|
if writeErr := os.WriteFile(certFile, cert, 0600); writeErr != nil {
|
|
s.logger.Error("Failed to write certificate file", "error", writeErr)
|
|
err = writeErr
|
|
} else if writeErr := os.WriteFile(keyFile, key, 0600); writeErr != nil {
|
|
s.logger.Error("Failed to write key file", "error", writeErr)
|
|
err = writeErr
|
|
} else {
|
|
s.logger.Info("Self-signed certificate generated", "certFile", certFile, "keyFile", keyFile)
|
|
err = s.server.ListenAndServeTLS(certFile, keyFile)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Start HTTP/1.1 server
|
|
s.logger.Info("Using HTTP/1.1 (no TLS)")
|
|
s.server.TLSConfig = nil
|
|
err = s.server.ListenAndServe()
|
|
}
|
|
|
|
if err != nil && err != http.ErrServerClosed {
|
|
s.logger.Error("HTTP server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper function to check if a file exists
|
|
func fileExists(filename string) bool {
|
|
_, err := os.Stat(filename)
|
|
return err == nil
|
|
}
|
|
|
|
// generateSelfSignedCert creates a self-signed certificate for development
|
|
func generateSelfSignedCert() ([]byte, []byte, error) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Derby Race Manager"},
|
|
CommonName: "localhost",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
DNSNames: []string{"localhost"},
|
|
}
|
|
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
certPEM := &bytes.Buffer{}
|
|
pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
|
|
|
keyPEM := &bytes.Buffer{}
|
|
pem.Encode(keyPEM, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
|
|
|
return certPEM.Bytes(), keyPEM.Bytes(), nil
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the server
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
s.logger.Info("Shutting down web server")
|
|
|
|
// Signal event forwarder to stop
|
|
close(s.shutdown)
|
|
|
|
// Close all client connections with a timeout
|
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Close all client connections
|
|
s.clientsMux.Lock()
|
|
for clientChan := range s.clients {
|
|
close(clientChan)
|
|
}
|
|
s.clients = make(map[chan string]bool)
|
|
s.clientsMux.Unlock()
|
|
|
|
s.adminclientsMux.Lock()
|
|
for clientChan := range s.adminclients {
|
|
close(clientChan)
|
|
}
|
|
s.adminclients = make(map[chan string]bool)
|
|
s.adminclientsMux.Unlock()
|
|
|
|
// Shutdown the HTTP server
|
|
err := s.server.Shutdown(shutdownCtx)
|
|
if err != nil {
|
|
// If shutdown times out, force close
|
|
s.logger.Warn("Server shutdown timed out, forcing close", "error", err)
|
|
return s.server.Close()
|
|
}
|
|
|
|
s.logger.Info("Web server shutdown complete")
|
|
return nil
|
|
}
|
|
|
|
// forwardEvents forwards derby events to SSE clients
|
|
func (s *Server) forwardEvents() {
|
|
s.logger.Info("Starting event forwarder")
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-s.events:
|
|
if !ok {
|
|
s.logger.Warn("Events channel closed, stopping forwarder")
|
|
return
|
|
}
|
|
|
|
s.logger.Debug("Received event from derby clock",
|
|
"type", event.Type,
|
|
"clientCount", len(s.clients))
|
|
|
|
// Process the event and send to clients
|
|
s.broadcastRaceEvent(event)
|
|
|
|
case <-s.shutdown:
|
|
s.logger.Info("Shutdown signal received, stopping forwarder")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastRaceEvent sends a race event to all connected clients
|
|
func (s *Server) broadcastRaceEvent(event derby.Event) {
|
|
switch event.Type {
|
|
case derby.EventRaceStart:
|
|
s.logger.Info("Broadcasting race start event")
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-success'>Race Running</div>\n\n")
|
|
|
|
case derby.EventLaneFinish:
|
|
s.logger.Info("Broadcasting lane finish event",
|
|
"lane", event.Result.Lane,
|
|
"time", event.Result.Time,
|
|
"place", event.Result.FinishPlace)
|
|
|
|
heatGroup, _ := s.db.GetCurrentRacingGroup()
|
|
heatNum, _ := s.db.GetCurrentHeatNumber(heatGroup.ID)
|
|
s.db.SaveLaneResult(heatGroup.ID, heatNum, event.Result.Lane, event.Result.Time, event.Result.FinishPlace)
|
|
|
|
s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-time\ndata: %.3f\n\n", event.Result.Lane, event.Result.Time))
|
|
s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-position\ndata: %d\n\n", event.Result.Lane, event.Result.FinishPlace))
|
|
|
|
case derby.EventRaceComplete:
|
|
s.logger.Info("Broadcasting race complete event")
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-danger'>Race Complete</div>\n\n")
|
|
heatGroup, _ := s.db.GetCurrentRacingGroup()
|
|
heatResults, _ := s.db.GetHeatResults(heatGroup.ID)
|
|
component := templates.ResultsDisplay(heatResults)
|
|
var sb strings.Builder
|
|
err := component.Render(context.Background(), &sb)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render current heat results", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: results\ndata: %s\n\n", sb.String()))
|
|
}
|
|
}
|
|
|
|
// broadcastEvent sends an event to all connected clients
|
|
func (s *Server) broadcastAdminEvent(event models.AdminEvent) {
|
|
switch event.Type {
|
|
case models.EventHeatChanged:
|
|
s.logger.Info("Broadcasting heat changed event")
|
|
if heatData, ok := event.Event.(models.HeatData); ok {
|
|
component := templates.CurrentHeatDisplay(&heatData)
|
|
var sb strings.Builder
|
|
err := component.Render(context.Background(), &sb)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render current heat display", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: current-heat\ndata: %s\n\n", sb.String()))
|
|
|
|
component = templates.RaceCurrentHeatLanes(&heatData)
|
|
var sb2 strings.Builder
|
|
err = component.Render(context.Background(), &sb2)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render current heat lanes", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: lane-data\ndata: %s\n\n", sb2.String()))
|
|
|
|
nextHeatData, _ := s.db.GetHeatData(heatData.Group.ID, heatData.HeatNumber+1)
|
|
component = templates.NextHeatDisplay(nextHeatData)
|
|
var sb3 strings.Builder
|
|
err = component.Render(context.Background(), &sb2)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render next heat display", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: next-heat\ndata: %s\n\n", sb3.String()))
|
|
|
|
component = templates.RaceNextHeatPreview(nextHeatData)
|
|
var sb4 strings.Builder
|
|
err = component.Render(context.Background(), &sb4)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render next heat preview", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: next-public-heat\ndata: %s\n\n", sb4.String()))
|
|
|
|
upcomingHeatData, _ := s.db.GetHeatData(heatData.Group.ID, heatData.HeatNumber+2)
|
|
component = templates.RaceNextHeatPreview(upcomingHeatData)
|
|
var sb5 strings.Builder
|
|
err = component.Render(context.Background(), &sb5)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render upcoming heat preview", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: upcoming-public-heat\ndata: %s\n\n", sb5.String()))
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: heat-number\ndata: %s - Heat: %d of %d\n\n", heatData.Group.Name, heatData.HeatNumber, heatData.TotalHeats))
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: group-name\ndata: %s\n\n", heatData.Group.Name))
|
|
} else {
|
|
s.logger.Error("Failed to convert event to HeatData")
|
|
}
|
|
|
|
case models.EventGroupChanged:
|
|
s.logger.Info("Broadcasting group changed event",
|
|
"group", event.Event)
|
|
|
|
if heatResults, ok := event.Event.([]models.HeatResult); ok {
|
|
component := templates.ResultsDisplay(heatResults)
|
|
var sb strings.Builder
|
|
err := component.Render(context.Background(), &sb)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render current heat results", "error", err)
|
|
}
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: results\ndata: %s\n\n", sb.String()))
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendRaceEventToAllClients sends an event to all connected clients
|
|
func (s *Server) sendRaceEventToAllClients(message string) {
|
|
// Log the message being sent (truncate if too long)
|
|
msgToLog := message
|
|
if len(msgToLog) > 100 {
|
|
msgToLog = msgToLog[:100] + "..."
|
|
}
|
|
s.logger.Debug("Sending message to all clients",
|
|
"message", msgToLog,
|
|
"clientCount", len(s.clients))
|
|
|
|
// Make a copy of the clients map to avoid holding the lock while sending
|
|
s.clientsMux.Lock()
|
|
clientsToSend := make([]chan string, 0, len(s.clients))
|
|
for clientChan := range s.clients {
|
|
clientsToSend = append(clientsToSend, clientChan)
|
|
}
|
|
s.clientsMux.Unlock()
|
|
|
|
s.logger.Debug("Prepared to send to clients", "count", len(clientsToSend))
|
|
|
|
// Count successful and failed sends
|
|
successCount := 0
|
|
failCount := 0
|
|
|
|
// Send to all clients without holding the lock
|
|
for _, clientChan := range clientsToSend {
|
|
select {
|
|
case clientChan <- message:
|
|
// Message sent successfully
|
|
successCount++
|
|
default:
|
|
// Client is not receiving, remove it
|
|
s.clientsMux.Lock()
|
|
delete(s.clients, clientChan)
|
|
s.clientsMux.Unlock()
|
|
close(clientChan)
|
|
failCount++
|
|
}
|
|
}
|
|
|
|
s.logger.Debug("Finished sending message",
|
|
"successCount", successCount,
|
|
"failCount", failCount,
|
|
"remainingClients", len(s.clients))
|
|
}
|
|
|
|
// sendAdminEventToAllClients sends an event to all connected clients
|
|
func (s *Server) sendAdminEventToAllClients(message string) {
|
|
// Log the message being sent (truncate if too long)
|
|
msgToLog := message
|
|
if len(msgToLog) > 100 {
|
|
msgToLog = msgToLog[:100] + "..."
|
|
}
|
|
s.logger.Debug("Sending message to all clients",
|
|
"message", msgToLog,
|
|
"clientCount", len(s.adminclients))
|
|
|
|
// Make a copy of the clients map to avoid holding the lock while sending
|
|
s.adminclientsMux.Lock()
|
|
clientsToSend := make([]chan string, 0, len(s.adminclients))
|
|
for clientChan := range s.adminclients {
|
|
clientsToSend = append(clientsToSend, clientChan)
|
|
}
|
|
s.adminclientsMux.Unlock()
|
|
|
|
s.logger.Debug("Prepared to send to clients", "count", len(clientsToSend))
|
|
|
|
// Count successful and failed sends
|
|
successCount := 0
|
|
failCount := 0
|
|
|
|
// Send to all clients without holding the lock
|
|
for _, clientChan := range clientsToSend {
|
|
select {
|
|
case clientChan <- message:
|
|
// Message sent successfully
|
|
successCount++
|
|
default:
|
|
// Client is not receiving, remove it
|
|
s.adminclientsMux.Lock()
|
|
delete(s.adminclients, clientChan)
|
|
s.adminclientsMux.Unlock()
|
|
close(clientChan)
|
|
failCount++
|
|
}
|
|
}
|
|
|
|
s.logger.Debug("Finished sending message",
|
|
"successCount", successCount,
|
|
"failCount", failCount,
|
|
"remainingClients", len(s.adminclients))
|
|
}
|
|
|
|
// handleEvents handles SSE events
|
|
func (s *Server) handleEvents() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
requestID := middleware.GetReqID(r.Context())
|
|
if requestID == "" {
|
|
requestID = fmt.Sprintf("req-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
s.logger.Debug("SSE connection request received",
|
|
"requestID", requestID,
|
|
"remoteAddr", r.RemoteAddr,
|
|
"userAgent", r.UserAgent())
|
|
|
|
// Set headers for SSE
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
// Flush headers to ensure they're sent to the client
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
s.logger.Debug("Flushing headers", "requestID", requestID)
|
|
flusher.Flush()
|
|
} else {
|
|
s.logger.Error("Streaming unsupported!", "requestID", requestID)
|
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create a channel for this client
|
|
clientChan := make(chan string, 10)
|
|
|
|
// Add client to map with mutex protection
|
|
s.clientsMux.Lock()
|
|
s.clients[clientChan] = true
|
|
clientCount := len(s.clients)
|
|
s.clientsMux.Unlock()
|
|
|
|
s.logger.Debug("New client connected",
|
|
"requestID", requestID,
|
|
"clientIP", r.RemoteAddr,
|
|
"totalClients", clientCount)
|
|
|
|
// Send a ping immediately to test the connection
|
|
pingMsg := "event: ping\ndata: connection established\n\n"
|
|
fmt.Fprint(w, pingMsg)
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
s.logger.Debug("Initial ping sent", "requestID", requestID)
|
|
}
|
|
|
|
// Remove client when connection is closed
|
|
defer func() {
|
|
s.clientsMux.Lock()
|
|
delete(s.clients, clientChan)
|
|
remainingClients := len(s.clients)
|
|
s.clientsMux.Unlock()
|
|
close(clientChan)
|
|
|
|
s.logger.Debug("Client disconnected",
|
|
"requestID", requestID,
|
|
"clientIP", r.RemoteAddr,
|
|
"remainingClients", remainingClients)
|
|
}()
|
|
|
|
// Set up a heartbeat to keep the connection alive
|
|
heartbeat := time.NewTicker(30 * time.Second)
|
|
defer heartbeat.Stop()
|
|
|
|
// Keep connection open and send events as they arrive
|
|
for {
|
|
select {
|
|
case msg, ok := <-clientChan:
|
|
if !ok {
|
|
s.logger.Warn("Client channel closed", "requestID", requestID)
|
|
return
|
|
}
|
|
|
|
s.logger.Debug("Sending message to client",
|
|
"requestID", requestID,
|
|
"messageLength", len(msg))
|
|
|
|
fmt.Fprint(w, msg)
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
s.logger.Debug("Message flushed", "requestID", requestID)
|
|
} else {
|
|
s.logger.Warn("Could not flush - client may not receive updates", "requestID", requestID)
|
|
}
|
|
|
|
case <-heartbeat.C:
|
|
// Send a heartbeat to keep the connection alive
|
|
s.logger.Debug("Sending heartbeat", "requestID", requestID)
|
|
fmt.Fprint(w, "event: ping\ndata: heartbeat\n\n")
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
|
|
case <-r.Context().Done():
|
|
s.logger.Debug("Client context done",
|
|
"requestID", requestID,
|
|
"error", r.Context().Err())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleIndex handles the index page
|
|
func (s *Server) handleIndex() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
templates.Index().Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
// handleReset handles the reset API endpoint
|
|
func (s *Server) handleReset() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.clock.Reset(); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-warning'>Ready</div>\n\n")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status": "reset"}`))
|
|
}
|
|
}
|
|
|
|
// handleForceEnd handles the force end API endpoint
|
|
func (s *Server) handleForceEnd() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.clock.ForceEnd(); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status": "forced"}`))
|
|
}
|
|
}
|
|
|
|
// handleStatus handles the status API endpoint
|
|
func (s *Server) handleStatus() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
status := s.clock.Status()
|
|
|
|
var statusStr string
|
|
switch status {
|
|
case derby.StatusIdle:
|
|
statusStr = "idle"
|
|
case derby.StatusRunning:
|
|
statusStr = "running"
|
|
case derby.StatusFinished:
|
|
statusStr = "finished"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr)))
|
|
}
|
|
}
|
|
|
|
// handleAdminEvents handles SSE events
|
|
func (s *Server) handleAdminEvents() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
s.logger.Debug("Admin events SSE connection established")
|
|
// Set headers for SSE
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
// Flush headers to ensure they're sent to the client
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
} else {
|
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.logger.Debug("Admin events SSE headers flushed")
|
|
|
|
// Create a channel for this client
|
|
clientChan := make(chan string, 10)
|
|
s.logger.Debug("Admin events SSE client channel created")
|
|
// Add client to map with mutex protection
|
|
s.adminclientsMux.Lock()
|
|
s.adminclients[clientChan] = true
|
|
clientCount := len(s.adminclients)
|
|
s.adminclientsMux.Unlock()
|
|
s.logger.Debug("Admin events SSE client added to map")
|
|
|
|
s.logger.Debug("New client connected",
|
|
"clientIP", r.RemoteAddr,
|
|
"totalClients", clientCount)
|
|
|
|
// Remove client when connection is closed
|
|
defer func() {
|
|
s.adminclientsMux.Lock()
|
|
delete(s.adminclients, clientChan)
|
|
remainingClients := len(s.adminclients)
|
|
s.adminclientsMux.Unlock()
|
|
close(clientChan)
|
|
|
|
s.logger.Debug("Client disconnected",
|
|
"clientIP", r.RemoteAddr,
|
|
"remainingClients", remainingClients)
|
|
}()
|
|
|
|
// Keep connection open and send events as they arrive
|
|
for {
|
|
select {
|
|
case msg, ok := <-clientChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "%s\n\n", msg)
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAdmin renders the admin page
|
|
func (s *Server) handleAdmin() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups and racers from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
racers, err := s.db.GetRacers()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Admin(groups, racers)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render admin template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRegister renders the racer registration page
|
|
func (s *Server) handleRegister() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Register(groups)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render register template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRegisterForm returns just the registration form component
|
|
func (s *Server) handleRegisterForm() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render form with isAdmin=true
|
|
component := templates.RegisterForm(groups, true)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render form", "error", err)
|
|
http.Error(w, "Failed to render form", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// API handlers for groups
|
|
|
|
// handleCreateGroup creates a new group
|
|
func (s *Server) handleCreateGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
// Validate
|
|
if name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create group
|
|
id, err := s.db.CreateGroup(name, description)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create group", "error", err)
|
|
http.Error(w, "Failed to create group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleUpdateGroup updates a group
|
|
func (s *Server) handleUpdateGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
// Validate
|
|
if name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update group
|
|
if err := s.db.UpdateGroup(id, name, description); err != nil {
|
|
s.logger.Error("Failed to update group", "error", err)
|
|
http.Error(w, "Failed to update group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleDeleteGroup deletes a group
|
|
func (s *Server) handleDeleteGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete group
|
|
if err := s.db.DeleteGroup(id); err != nil {
|
|
s.logger.Error("Failed to delete group", "error", err)
|
|
http.Error(w, "Failed to delete group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"success":true}`)
|
|
}
|
|
}
|
|
|
|
// API handlers for racers
|
|
|
|
// handleCreateRacer creates a new racer
|
|
func (s *Server) handleCreateRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
firstName := r.FormValue("first_name")
|
|
lastName := r.FormValue("last_name")
|
|
carNumber := r.FormValue("car_number")
|
|
carWeightStr := r.FormValue("car_weight")
|
|
groupIDStr := r.FormValue("group_id")
|
|
|
|
// Validate
|
|
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
|
|
http.Error(w, "All fields are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse numeric values
|
|
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid car weight", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if car number is unique before creating
|
|
isUnique, err := s.db.IsCarNumberUnique(carNumber)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check car number uniqueness", "error", err)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-danger">Failed to validate car number</div>`))
|
|
return
|
|
}
|
|
|
|
if !isUnique {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-danger">Car number is already in use</div>`))
|
|
return
|
|
}
|
|
|
|
id, err := s.db.CreateRacer(firstName, lastName, carNumber, carWeight, groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create racer", "error", err)
|
|
http.Error(w, "Failed to create racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.logger.Info("Racer created", "id", id)
|
|
|
|
// Broadcast event to admin page
|
|
select {
|
|
case s.adminEvents <- "racer-added":
|
|
// Event sent
|
|
default:
|
|
// Channel full, non-blocking
|
|
}
|
|
|
|
// Return success message
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-success">Racer added successfully!</div>`))
|
|
}
|
|
}
|
|
|
|
// handleUpdateRacer updates a racer
|
|
func (s *Server) handleUpdateRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
firstName := r.FormValue("first_name")
|
|
lastName := r.FormValue("last_name")
|
|
carNumber := r.FormValue("car_number")
|
|
carWeightStr := r.FormValue("car_weight")
|
|
groupIDStr := r.FormValue("group_id")
|
|
|
|
// Validate
|
|
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
|
|
http.Error(w, "All fields are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse numeric values
|
|
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid car weight", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update racer
|
|
if err := s.db.UpdateRacer(id, firstName, lastName, carNumber, carWeight, groupID); err != nil {
|
|
s.logger.Error("Failed to update racer", "error", err)
|
|
http.Error(w, "Failed to update racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleDeleteRacer deletes a racer
|
|
func (s *Server) handleDeleteRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete racer
|
|
if err := s.db.DeleteRacer(id); err != nil {
|
|
s.logger.Error("Failed to delete racer", "error", err)
|
|
http.Error(w, "Failed to delete racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"success":true}`)
|
|
}
|
|
}
|
|
|
|
// handleHeats renders the heats page
|
|
func (s *Server) handleHeats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get selected group ID from query parameter
|
|
selectedGroupID := int64(0)
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr != "" {
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err == nil {
|
|
selectedGroupID = groupID
|
|
}
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Heats(groups, selectedGroupID, s.db)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render heats template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleHeatsContent renders the heats content
|
|
func (s *Server) handleHeatsContent() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get selected group ID from query parameter
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr == "" {
|
|
http.Error(w, "Group ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check for saved heats
|
|
hasSavedHeats, err := s.db.HasSavedHeats(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check for saved heats", "error", err)
|
|
http.Error(w, "Failed to check for saved heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var heats []models.Heat
|
|
if hasSavedHeats {
|
|
// Get saved heats
|
|
heats, err = s.db.GetHeats(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get saved heats", "error", err)
|
|
http.Error(w, "Failed to get saved heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get racers for the group
|
|
racers, err := s.db.GetRacersByGroup(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert models.Racer to derby.Racer
|
|
derbyRacers := make([]derby.Racer, len(racers))
|
|
for i, r := range racers {
|
|
derbyRacers[i] = derby.Racer{
|
|
ID: r.ID,
|
|
FirstName: r.FirstName,
|
|
LastName: r.LastName,
|
|
CarNumber: r.CarNumber,
|
|
}
|
|
}
|
|
|
|
// Generate and convert heats
|
|
if !hasSavedHeats && len(racers) > 0 {
|
|
derbyHeats := derby.GenerateHeats(derbyRacers)
|
|
heats = make([]models.Heat, len(derbyHeats))
|
|
for i, h := range derbyHeats {
|
|
heats[i] = models.Heat{
|
|
HeatNum: i + 1,
|
|
Lane1ID: h.Lane1ID,
|
|
Lane2ID: h.Lane2ID,
|
|
Lane3ID: h.Lane3ID,
|
|
Lane4ID: h.Lane4ID,
|
|
}
|
|
}
|
|
s.db.SaveHeats(groupID, heats)
|
|
}
|
|
|
|
// Render template
|
|
component := templates.HeatsContent(heats, racers, groupID)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render heats content", "error", err)
|
|
http.Error(w, "Failed to render content", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleGenerateHeats generates heats for a group
|
|
func (s *Server) handleGenerateHeats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get selected group ID from query parameter
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr == "" {
|
|
http.Error(w, "Group ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get racers for the group
|
|
racers, err := s.db.GetRacersByGroup(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert models.Racer to derby.Racer
|
|
derbyRacers := make([]derby.Racer, len(racers))
|
|
for i, r := range racers {
|
|
derbyRacers[i] = derby.Racer{
|
|
ID: r.ID,
|
|
FirstName: r.FirstName,
|
|
LastName: r.LastName,
|
|
CarNumber: r.CarNumber,
|
|
}
|
|
}
|
|
|
|
// Generate and convert heats
|
|
derbyHeats := derby.GenerateHeats(derbyRacers)
|
|
heats := make([]models.Heat, len(derbyHeats))
|
|
for i, h := range derbyHeats {
|
|
heats[i] = models.Heat{
|
|
HeatNum: i + 1,
|
|
Lane1ID: h.Lane1ID,
|
|
Lane2ID: h.Lane2ID,
|
|
Lane3ID: h.Lane3ID,
|
|
Lane4ID: h.Lane4ID,
|
|
}
|
|
}
|
|
s.db.SaveHeats(groupID, heats)
|
|
|
|
// Render template
|
|
component := templates.HeatsContent(heats, racers, groupID)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render heats content", "error", err)
|
|
http.Error(w, "Failed to render content", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleSaveHeats saves heats for a group
|
|
func (s *Server) handleSaveHeats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse request body
|
|
var request struct {
|
|
GroupID int64 `json:"group_id"`
|
|
Heats []models.Heat `json:"heats"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Save heats
|
|
if err := s.db.SaveHeats(request.GroupID, request.Heats); err != nil {
|
|
s.logger.Error("Failed to save heats", "error", err)
|
|
http.Error(w, "Failed to save heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// handleRacersList renders the racers list component
|
|
func (s *Server) handleRacersList() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get racers and groups
|
|
racers, err := s.db.GetRacers()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render just the racers list component
|
|
component := templates.RacersList(racers, groups)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render racers list", "error", err)
|
|
http.Error(w, "Failed to render racers list", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleValidateCarNumber handles the validate car number API endpoint
|
|
func (s *Server) handleValidateCarNumber() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
carNumber := r.URL.Query().Get("car_number")
|
|
if carNumber == "" {
|
|
http.Error(w, "<div id='car-number-validation-error' class='alert alert-danger'>Car number is required</div>", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isUnique, err := s.db.IsCarNumberUnique(carNumber)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check car number uniqueness", "error", err)
|
|
http.Error(w, "<div id='car-number-validation-error' class='alert alert-danger'>Failed to validate car number</div>", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if !isUnique {
|
|
w.Write([]byte("<div id='car-number-validation-error' class='alert alert-danger'>Car number is already in use</div>"))
|
|
} else {
|
|
w.Write([]byte("<div class='invalid-feedback' id='car-number-validation-error'></div>"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRacePublic renders the public race view page
|
|
func (s *Server) handleRacePublic() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get heats for the group
|
|
heatData, err := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heats", "error", err)
|
|
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get next heat data
|
|
nextHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
|
|
|
|
// Get on-deck heat data
|
|
onDeckHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+2)
|
|
|
|
// Render template
|
|
component := templates.RacePublic(heatData, nextHeatData, onDeckHeatData)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render race public template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRaceManage renders the race management page
|
|
func (s *Server) handleRaceManage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
s.logger.Info("Rendering race manage template")
|
|
// Get all groups
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get heat results
|
|
results, err := s.db.GetHeatResults(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heat results", "error", err)
|
|
http.Error(w, "Failed to get heat results", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get heats for the group
|
|
heatData, err := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heats", "error", err)
|
|
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get next heat data
|
|
nextHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
|
|
|
|
s.logger.Info("Rendering race manage template", "heatData", heatData, "nextHeatData", nextHeatData, "groups", groups, "results", results)
|
|
// Render template
|
|
component := templates.RaceManage(heatData, nextHeatData, groups, results)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render race manage template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// API handlers for race management
|
|
|
|
// handleCurrentHeat returns the current heat data
|
|
func (s *Server) handleCurrentHeat() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get heats for the group
|
|
heats, err := s.db.GetHeats(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heats", "error", err)
|
|
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get racers for the group
|
|
racers, err := s.db.GetRacersByGroup(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Find current heat
|
|
var currentHeat models.Heat
|
|
for _, heat := range heats {
|
|
if heat.HeatNum == currentHeatNum {
|
|
currentHeat = heat
|
|
break
|
|
}
|
|
}
|
|
|
|
// Create response with racer details
|
|
type LaneInfo struct {
|
|
Lane int `json:"lane"`
|
|
RacerID int64 `json:"racer_id"`
|
|
Name string `json:"name"`
|
|
CarNum string `json:"car_number"`
|
|
Time interface{} `json:"time"`
|
|
Position interface{} `json:"position"`
|
|
}
|
|
|
|
response := struct {
|
|
HeatNumber int `json:"heat_number"`
|
|
TotalHeats int `json:"total_heats"`
|
|
GroupID int64 `json:"group_id"`
|
|
GroupName string `json:"group_name"`
|
|
Lanes []LaneInfo `json:"lanes"`
|
|
}{
|
|
HeatNumber: currentHeatNum,
|
|
TotalHeats: len(heats),
|
|
GroupID: currentGroup.ID,
|
|
GroupName: currentGroup.Name,
|
|
Lanes: make([]LaneInfo, 0),
|
|
}
|
|
|
|
// Get heat result if available
|
|
result, err := s.db.GetHeatResult(currentGroup.ID, currentHeatNum)
|
|
if err != nil && err != db.ErrNotFound {
|
|
s.logger.Error("Failed to get heat result", "error", err)
|
|
}
|
|
|
|
// Add lane 1 info
|
|
if currentHeat.Lane1ID != nil {
|
|
lane := LaneInfo{Lane: 1, RacerID: *currentHeat.Lane1ID}
|
|
for _, racer := range racers {
|
|
if racer.ID == *currentHeat.Lane1ID {
|
|
lane.Name = racer.FirstName + " " + racer.LastName
|
|
lane.CarNum = racer.CarNumber
|
|
break
|
|
}
|
|
}
|
|
if result != nil {
|
|
lane.Time = result.Lane1Time
|
|
lane.Position = result.Lane1Position
|
|
}
|
|
response.Lanes = append(response.Lanes, lane)
|
|
}
|
|
|
|
// Add lane 2 info
|
|
if currentHeat.Lane2ID != nil {
|
|
lane := LaneInfo{Lane: 2, RacerID: *currentHeat.Lane2ID}
|
|
for _, racer := range racers {
|
|
if racer.ID == *currentHeat.Lane2ID {
|
|
lane.Name = racer.FirstName + " " + racer.LastName
|
|
lane.CarNum = racer.CarNumber
|
|
break
|
|
}
|
|
}
|
|
if result != nil {
|
|
lane.Time = result.Lane2Time
|
|
lane.Position = result.Lane2Position
|
|
}
|
|
response.Lanes = append(response.Lanes, lane)
|
|
}
|
|
|
|
// Add lane 3 info
|
|
if currentHeat.Lane3ID != nil {
|
|
lane := LaneInfo{Lane: 3, RacerID: *currentHeat.Lane3ID}
|
|
for _, racer := range racers {
|
|
if racer.ID == *currentHeat.Lane3ID {
|
|
lane.Name = racer.FirstName + " " + racer.LastName
|
|
lane.CarNum = racer.CarNumber
|
|
break
|
|
}
|
|
}
|
|
if result != nil {
|
|
lane.Time = result.Lane3Time
|
|
lane.Position = result.Lane3Position
|
|
}
|
|
response.Lanes = append(response.Lanes, lane)
|
|
}
|
|
|
|
// Add lane 4 info
|
|
if currentHeat.Lane4ID != nil {
|
|
lane := LaneInfo{Lane: 4, RacerID: *currentHeat.Lane4ID}
|
|
for _, racer := range racers {
|
|
if racer.ID == *currentHeat.Lane4ID {
|
|
lane.Name = racer.FirstName + " " + racer.LastName
|
|
lane.CarNum = racer.CarNumber
|
|
break
|
|
}
|
|
}
|
|
if result != nil {
|
|
lane.Time = result.Lane4Time
|
|
lane.Position = result.Lane4Position
|
|
}
|
|
response.Lanes = append(response.Lanes, lane)
|
|
}
|
|
|
|
// Return JSON response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
}
|
|
|
|
// handleNextHeat advances to the next heat
|
|
func (s *Server) handleNextHeat() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get heats for the group
|
|
heats, err := s.db.GetHeats(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heats", "error", err)
|
|
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check if we're already at the last heat
|
|
if currentHeatNum >= len(heats) {
|
|
http.Error(w, "Already at the last heat", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Advance to next heat
|
|
if err := s.db.SetCurrentHeatNumber(currentGroup.ID, currentHeatNum+1); err != nil {
|
|
s.logger.Error("Failed to set current heat number", "error", err)
|
|
http.Error(w, "Failed to advance to next heat", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
|
|
|
|
// Broadcast event to admin page
|
|
s.broadcastAdminEvent(models.AdminEvent{
|
|
Type: models.EventHeatChanged,
|
|
Event: *heatData,
|
|
})
|
|
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-primary'>Idle</div>\n\n")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// handlePreviousHeat goes back to the previous heat
|
|
func (s *Server) handlePreviousHeat() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check if we're already at the first heat
|
|
if currentHeatNum <= 1 {
|
|
http.Error(w, "Already at the first heat", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Go back to previous heat
|
|
if err := s.db.SetCurrentHeatNumber(currentGroup.ID, currentHeatNum-1); err != nil {
|
|
s.logger.Error("Failed to set current heat number", "error", err)
|
|
http.Error(w, "Failed to go back to previous heat", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum-1)
|
|
|
|
// Broadcast event to admin page
|
|
s.broadcastAdminEvent(models.AdminEvent{
|
|
Type: models.EventHeatChanged,
|
|
Event: *heatData,
|
|
})
|
|
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-primary'>Idle</div>\n\n")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// handleRerunHeat marks a heat for rerun
|
|
func (s *Server) handleRerunHeat() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get current heat number
|
|
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current heat number", "error", err)
|
|
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete any existing result for this heat
|
|
if err := s.db.DeleteHeatResult(currentGroup.ID, currentHeatNum); err != nil {
|
|
s.logger.Error("Failed to delete heat result", "error", err)
|
|
http.Error(w, "Failed to mark heat for rerun", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
|
|
|
|
// Broadcast event to admin page
|
|
s.broadcastAdminEvent(models.AdminEvent{
|
|
Type: models.EventHeatChanged,
|
|
Event: *heatData,
|
|
})
|
|
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-primary'>Idle</div>\n\n")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
func parseRequestBody(r *http.Request) (string, error) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// handleSetRacingGroup sets the currently racing group
|
|
func (s *Server) handleSetRacingGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
text, err := parseRequestBody(r)
|
|
if err != nil {
|
|
s.logger.Error("Failed to parse request body", "error", err)
|
|
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.logger.Info("Set racing group request", "text", text)
|
|
|
|
// Parse request body
|
|
var request struct {
|
|
GroupID int64 `json:"group_id"`
|
|
}
|
|
request.GroupID, err = strconv.ParseInt(strings.Split(text, "=")[1], 10, 64)
|
|
if err != nil {
|
|
s.logger.Error("Failed to parse group ID", "error", err)
|
|
http.Error(w, "Failed to parse group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Set current racing group
|
|
if err := s.db.SetCurrentRacingGroup(request.GroupID); err != nil {
|
|
s.logger.Error("Failed to set current racing group", "error", err)
|
|
http.Error(w, "Failed to set current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
heatData, err := s.db.GetHeatData(request.GroupID, 1)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get heat data", "error", err)
|
|
http.Error(w, "Failed to get heat data", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Broadcast event to admin page
|
|
s.broadcastAdminEvent(models.AdminEvent{
|
|
Type: models.EventHeatChanged,
|
|
Event: *heatData,
|
|
})
|
|
|
|
heatResults, _ := s.db.GetHeatResults(request.GroupID)
|
|
|
|
s.broadcastAdminEvent(models.AdminEvent{
|
|
Type: models.EventGroupChanged,
|
|
Event: heatResults,
|
|
})
|
|
|
|
s.sendRaceEventToAllClients("event: race-status\ndata: <div id='status-indicator' class='w-25 h-100 d-inline-flex align-items-center justify-content-center badge bg-primary'>Idle</div>\n\n")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// handleFinalResults renders the final results page
|
|
func (s *Server) handleFinalResults() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get selected group ID from query parameter
|
|
selectedGroupID := int64(0)
|
|
selectedGroupName := ""
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr != "" {
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err == nil {
|
|
selectedGroupID = groupID
|
|
|
|
// Find the group name
|
|
for _, group := range groups {
|
|
if group.ID == selectedGroupID {
|
|
selectedGroupName = group.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var results []models.FinalResult
|
|
if selectedGroupID > 0 {
|
|
// Get final results for the selected group
|
|
results, err = s.db.GetFinalResults(selectedGroupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get final results", "error", err)
|
|
http.Error(w, "Failed to get final results", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Render template
|
|
component := templates.FinalResultsPage(groups, selectedGroupID, results, selectedGroupName)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render final results template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleFinalResultsPublic renders the public final results page
|
|
func (s *Server) handleFinalResultsPublic() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get group ID from query parameter if provided
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr != "" {
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err == nil {
|
|
// Override current group if specified in URL
|
|
currentGroup.ID = groupID
|
|
// Get the group name
|
|
group, err := s.db.GetGroup(groupID)
|
|
if err == nil {
|
|
currentGroup.Name = group.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get reveal count from query parameter
|
|
revealCount := 0
|
|
revealCountStr := r.URL.Query().Get("reveal")
|
|
if revealCountStr != "" {
|
|
count, err := strconv.Atoi(revealCountStr)
|
|
if err == nil {
|
|
revealCount = count
|
|
}
|
|
}
|
|
|
|
// Get final results for the group
|
|
results, err := s.db.GetFinalResults(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get final results", "error", err)
|
|
http.Error(w, "Failed to get final results", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render template
|
|
component := templates.FinalResultsPublic(results, currentGroup.Name, revealCount)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render final results template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRevealNextResult reveals the next result
|
|
func (s *Server) handleRevealNextResult() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get the currently racing group
|
|
currentGroup, err := s.db.GetCurrentRacingGroup()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get current racing group", "error", err)
|
|
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Get final results for the group
|
|
results, err := s.db.GetFinalResults(currentGroup.ID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get final results", "error", err)
|
|
http.Error(w, "Failed to get final results", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Broadcast the reveal event
|
|
var sb strings.Builder
|
|
|
|
component := templates.FinalResultsTable(results)
|
|
component.Render(context.Background(), &sb)
|
|
|
|
s.sendAdminEventToAllClients(fmt.Sprintf("event: results-reveal\ndata: %s\n\n", sb.String()))
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|