skyview/internal/server/server.go
Ole-Morten Duesund 4c9215b535 fix: Gate embedded files debug output behind verbose flag
The embedded files debug output was showing on every database page visit,
cluttering the logs with unnecessary information. Made it conditional on
the -verbose flag to reduce noise in production logs.

Changes:
- Added 'debug' field to Server struct
- Updated NewWebServer constructor to accept debug parameter
- Pass verbose flag from main.go to server constructor
- Made debugEmbeddedFiles() conditional on debug flag

Now debug output only appears when -verbose flag is used, keeping
normal operation clean while preserving debugging capability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:59:34 +02:00

1107 lines
36 KiB
Go

// Package server provides HTTP and WebSocket services for the SkyView application.
//
// This package implements the web server that serves both static assets and real-time
// aircraft data via REST API endpoints and WebSocket connections. It handles:
// - Static web file serving from embedded assets
// - RESTful API endpoints for aircraft, sources, and statistics
// - Real-time WebSocket streaming for live aircraft updates
// - CORS handling for cross-origin requests
// - Coverage and heatmap data generation for visualization
//
// The server integrates with the merger component to access consolidated aircraft
// data from multiple sources and provides various data formats optimized for
// web consumption.
package server
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"skyview/internal/database"
"skyview/internal/merger"
)
// OriginConfig represents the geographical reference point configuration.
// This is used as the center point for the web map interface and for
// distance calculations in coverage analysis.
type OriginConfig struct {
Latitude float64 `json:"latitude"` // Reference latitude in decimal degrees
Longitude float64 `json:"longitude"` // Reference longitude in decimal degrees
Name string `json:"name,omitempty"` // Descriptive name for the origin point
}
// Server handles HTTP requests and WebSocket connections for the SkyView web interface.
// It serves static web assets, provides RESTful API endpoints for aircraft data,
// and maintains real-time WebSocket connections for live updates.
//
// The server architecture uses:
// - Gorilla mux for HTTP routing
// - Gorilla WebSocket for real-time communication
// - Embedded filesystem for static asset serving
// - Concurrent broadcast system for WebSocket clients
// - CORS support for cross-origin web applications
type Server struct {
host string // Bind address for HTTP server
port int // TCP port for HTTP server
merger *merger.Merger // Data source for aircraft information
database *database.Database // Optional database for persistence
staticFiles embed.FS // Embedded static web assets
server *http.Server // HTTP server instance
origin OriginConfig // Geographic reference point
debug bool // Enable debug output
// WebSocket management
wsClients map[*websocket.Conn]bool // Active WebSocket client connections
wsClientsMu sync.RWMutex // Protects wsClients map
upgrader websocket.Upgrader // HTTP to WebSocket protocol upgrader
// Broadcast channels for real-time updates
broadcastChan chan []byte // Channel for broadcasting updates to all clients
stopChan chan struct{} // Shutdown signal channel
}
// WebSocketMessage represents the standard message format for WebSocket communication.
// All messages sent to clients follow this structure to provide consistent
// message handling and enable message type discrimination on the client side.
type WebSocketMessage struct {
Type string `json:"type"` // Message type ("initial_data", "aircraft_update", etc.)
Timestamp int64 `json:"timestamp"` // Unix timestamp when message was created
Data interface{} `json:"data"` // Message payload (varies by type)
}
// AircraftUpdate represents the complete aircraft data payload sent via WebSocket.
// This structure contains all information needed by the web interface to display
// current aircraft positions, source status, and system statistics.
type AircraftUpdate struct {
Aircraft map[string]*merger.AircraftState `json:"aircraft"` // Current aircraft keyed by ICAO hex string
Sources []*merger.Source `json:"sources"` // Active data sources with status
Stats map[string]interface{} `json:"stats"` // System statistics and metrics
}
// NewWebServer creates a new HTTP server instance for serving the SkyView web interface.
//
// The server is configured with:
// - WebSocket upgrader allowing all origins (suitable for development)
// - Buffered broadcast channel for efficient message distribution
// - Read/Write buffers optimized for aircraft data messages
//
// Parameters:
// - host: Bind address (empty for all interfaces, "localhost" for local only)
// - port: TCP port number for the HTTP server
// - merger: Data merger instance providing aircraft information
// - database: Optional database for persistence and callsign enhancement
// - staticFiles: Embedded filesystem containing web assets
// - origin: Geographic reference point for the map interface
// - debug: Enable debug output for troubleshooting
//
// Returns a configured but not yet started server instance.
func NewWebServer(host string, port int, merger *merger.Merger, database *database.Database, staticFiles embed.FS, origin OriginConfig, debug bool) *Server {
return &Server{
host: host,
port: port,
merger: merger,
database: database,
staticFiles: staticFiles,
origin: origin,
debug: debug,
wsClients: make(map[*websocket.Conn]bool),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
},
ReadBufferSize: 8192,
WriteBufferSize: 8192,
},
broadcastChan: make(chan []byte, 2000), // Increased buffer size to handle bursts
stopChan: make(chan struct{}),
}
}
// Start begins serving HTTP requests and WebSocket connections.
//
// This method starts several background routines:
// 1. Broadcast routine - handles WebSocket message distribution
// 2. Periodic update routine - sends regular updates to WebSocket clients
// 3. HTTP server - serves API endpoints and static files
//
// The method blocks until the server encounters an error or is shut down.
// Use Stop() for graceful shutdown.
//
// Returns an error if the server fails to start or encounters a fatal error.
func (s *Server) Start() error {
// Start broadcast routine
go s.broadcastRoutine()
// Start periodic updates
go s.periodicUpdateRoutine()
// Setup routes
router := s.setupRoutes()
// Format address correctly for IPv6
addr := fmt.Sprintf("%s:%d", s.host, s.port)
if strings.Contains(s.host, ":") {
// IPv6 address needs brackets
addr = fmt.Sprintf("[%s]:%d", s.host, s.port)
}
s.server = &http.Server{
Addr: addr,
Handler: router,
}
return s.server.ListenAndServe()
}
// Stop gracefully shuts down the server and all background routines.
//
// This method:
// 1. Signals all background routines to stop via stopChan
// 2. Shuts down the HTTP server with a 5-second timeout
// 3. Closes WebSocket connections
//
// The shutdown is designed to be safe and allow in-flight requests to complete.
func (s *Server) Stop() {
close(s.stopChan)
if s.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.server.Shutdown(ctx)
}
}
// setupRoutes configures the HTTP routing for all server endpoints.
//
// The routing structure includes:
// - /api/* - RESTful API endpoints for data access
// - /ws - WebSocket endpoint for real-time updates
// - /static/* - Static file serving
// - / - Main application page
//
// All routes are wrapped with CORS middleware for cross-origin support.
//
// Returns a configured HTTP handler ready for use with the HTTP server.
func (s *Server) setupRoutes() http.Handler {
router := mux.NewRouter()
// Health check endpoint for load balancers/monitoring
router.HandleFunc("/health", s.handleHealthCheck).Methods("GET")
// API routes
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
api.HandleFunc("/aircraft/{icao}", s.handleGetAircraftDetails).Methods("GET")
api.HandleFunc("/debug/aircraft", s.handleDebugAircraft).Methods("GET")
api.HandleFunc("/debug/websocket", s.handleDebugWebSocket).Methods("GET")
api.HandleFunc("/sources", s.handleGetSources).Methods("GET")
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
api.HandleFunc("/coverage/{sourceId}", s.handleGetCoverage).Methods("GET")
api.HandleFunc("/heatmap/{sourceId}", s.handleGetHeatmap).Methods("GET")
// Database API endpoints
api.HandleFunc("/database/status", s.handleGetDatabaseStatus).Methods("GET")
api.HandleFunc("/database/sources", s.handleGetDataSources).Methods("GET")
api.HandleFunc("/callsign/{callsign}", s.handleGetCallsignInfo).Methods("GET")
// WebSocket
router.HandleFunc("/ws", s.handleWebSocket)
// Static files
router.PathPrefix("/static/").Handler(s.staticFileHandler())
router.HandleFunc("/favicon.ico", s.handleFavicon)
// Main page
router.HandleFunc("/", s.handleIndex)
// Database status page
router.HandleFunc("/database", s.handleDatabasePage)
// Enable CORS
return s.enableCORS(router)
}
// isAircraftUseful determines if an aircraft has enough data to be useful for the frontend.
//
// DESIGN NOTE: We WANT reasonable aircraft to appear in our table view, even if they
// don't have enough data to appear on the map. This provides users visibility into
// all tracked aircraft, not just those with complete position data.
//
// Aircraft are considered useful if they have ANY of:
// - Valid position data (both latitude and longitude non-zero) -> Can show on map
// - Callsign (flight identification) -> Can show in table with "No position" status
// - Altitude information -> Can show in table as "Aircraft at X feet"
// - Any other identifying information that makes it a "real" aircraft
//
// This inclusive approach ensures the table view shows all aircraft we're tracking,
// while the map view only shows those with valid positions (handled by frontend filtering).
func (s *Server) isAircraftUseful(aircraft *merger.AircraftState) bool {
// Aircraft is useful if it has any meaningful data:
hasValidPosition := aircraft.Latitude != 0 && aircraft.Longitude != 0
hasCallsign := aircraft.Callsign != ""
hasAltitude := aircraft.Altitude != 0
hasSquawk := aircraft.Squawk != ""
// Include aircraft with any identifying or operational data
return hasValidPosition || hasCallsign || hasAltitude || hasSquawk
}
// handleHealthCheck serves the /health endpoint for monitoring and load balancers.
// Returns a simple health status with basic service information.
//
// Response includes:
// - status: "healthy" or "degraded"
// - uptime: server uptime in seconds
// - sources: number of active sources and their connection status
// - aircraft: current aircraft count
//
// The endpoint returns:
// - 200 OK when the service is healthy
// - 503 Service Unavailable when the service is degraded (no active sources)
func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
sources := s.merger.GetSources()
stats := s.addServerStats(s.merger.GetStatistics())
aircraft := s.merger.GetAircraft()
// Check if we have any active sources
activeSources := 0
for _, source := range sources {
if source.Active {
activeSources++
}
}
// Determine health status
status := "healthy"
statusCode := http.StatusOK
if activeSources == 0 && len(sources) > 0 {
status = "degraded"
statusCode = http.StatusServiceUnavailable
}
response := map[string]interface{}{
"status": status,
"timestamp": time.Now().Unix(),
"sources": map[string]interface{}{
"total": len(sources),
"active": activeSources,
},
"aircraft": map[string]interface{}{
"count": len(aircraft),
},
}
// Add statistics if available
if stats != nil {
if totalMessages, ok := stats["total_messages"]; ok {
response["messages"] = totalMessages
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
// handleGetAircraft serves the /api/aircraft endpoint.
// Returns all currently tracked aircraft with their latest state information.
//
// Only "useful" aircraft are returned - those with position data or callsign.
// This filters out incomplete aircraft that only have altitude or squawk codes,
// which are not actionable for frontend mapping and flight tracking.
//
// The response includes:
// - timestamp: Unix timestamp of the response
// - aircraft: Map of aircraft keyed by ICAO hex strings
// - count: Total number of useful aircraft (filtered count)
//
// Aircraft ICAO addresses are converted from uint32 to 6-digit hex strings
// for consistent JSON representation (e.g., 0xABC123 -> "ABC123").
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
// Convert ICAO keys to hex strings for JSON and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"aircraft": aircraftMap,
"count": len(aircraftMap), // Count of filtered useful aircraft
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetAircraftDetails serves the /api/aircraft/{icao} endpoint.
// Returns detailed information for a specific aircraft identified by ICAO address.
//
// The ICAO parameter should be a 6-digit hexadecimal string (e.g., "ABC123").
// Returns 400 Bad Request for invalid ICAO format.
// Returns 404 Not Found if the aircraft is not currently tracked.
//
// On success, returns the complete AircraftState for the requested aircraft.
func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
icaoStr := vars["icao"]
// Parse ICAO hex string
icao, err := strconv.ParseUint(icaoStr, 16, 32)
if err != nil {
http.Error(w, "Invalid ICAO address", http.StatusBadRequest)
return
}
aircraft := s.merger.GetAircraft()
if state, exists := aircraft[uint32(icao)]; exists {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(state)
} else {
http.Error(w, "Aircraft not found", http.StatusNotFound)
}
}
// handleGetSources serves the /api/sources endpoint.
// Returns information about all configured data sources and their current status.
//
// The response includes:
// - sources: Array of source configurations with connection status
// - count: Total number of configured sources
//
// This endpoint is useful for monitoring source connectivity and debugging
// multi-source setups.
func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
sources := s.merger.GetSources()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sources": sources,
"count": len(sources),
})
}
// handleGetStats serves the /api/stats endpoint.
// Returns system statistics and performance metrics from the data merger.
//
// Statistics may include:
// - Message processing rates
// - Aircraft count by source
// - Connection status
// - Data quality metrics
//
// The exact statistics depend on the merger implementation.
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
stats := s.addServerStats(s.merger.GetStatistics())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// handleGetOrigin serves the /api/origin endpoint.
// Returns the configured geographical reference point used by the system.
//
// The origin point is used for:
// - Default map center in the web interface
// - Distance calculations in coverage analysis
// - Range circle calculations
func (s *Server) handleGetOrigin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.origin)
}
// handleGetCoverage serves the /api/coverage/{sourceId} endpoint.
// Returns coverage data for a specific source based on aircraft positions and signal strength.
//
// The coverage data includes all positions where the specified source has received
// aircraft signals, along with signal strength and distance information.
// This is useful for visualizing receiver coverage patterns and range.
//
// Parameters:
// - sourceId: URL parameter identifying the source
//
// Returns array of coverage points with lat/lon, signal strength, distance, and altitude.
func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
// Generate coverage data based on signal strength
aircraft := s.merger.GetAircraft()
coveragePoints := make([]map[string]interface{}, 0)
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
coveragePoints = append(coveragePoints, map[string]interface{}{
"lat": state.Latitude,
"lon": state.Longitude,
"signal": srcData.SignalLevel,
"distance": srcData.Distance,
"altitude": state.Altitude,
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"source": sourceID,
"points": coveragePoints,
})
}
// handleGetHeatmap serves the /api/heatmap/{sourceId} endpoint.
// Generates a grid-based heatmap visualization of signal coverage for a specific source.
//
// The heatmap is computed by:
// 1. Finding geographic bounds of all aircraft positions for the source
// 2. Creating a 100x100 grid covering the bounds
// 3. Accumulating signal strength values in each grid cell
// 4. Returning the grid data with boundary coordinates
//
// This provides a density-based visualization of where the source receives
// the strongest signals, useful for coverage analysis and antenna optimization.
//
// Parameters:
// - sourceId: URL parameter identifying the source
//
// Returns grid data array and geographic bounds for visualization.
func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
// Generate heatmap data grid
aircraft := s.merger.GetAircraft()
heatmapData := make(map[string]interface{})
// Simple grid-based heatmap
grid := make([][]float64, 100)
for i := range grid {
grid[i] = make([]float64, 100)
}
// Find bounds
minLat, maxLat := 90.0, -90.0
minLon, maxLon := 180.0, -180.0
for _, state := range aircraft {
if _, exists := state.Sources[sourceID]; exists {
if state.Latitude < minLat {
minLat = state.Latitude
}
if state.Latitude > maxLat {
maxLat = state.Latitude
}
if state.Longitude < minLon {
minLon = state.Longitude
}
if state.Longitude > maxLon {
maxLon = state.Longitude
}
}
}
// Fill grid
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
latIdx := int((state.Latitude - minLat) / (maxLat - minLat) * 99)
lonIdx := int((state.Longitude - minLon) / (maxLon - minLon) * 99)
if latIdx >= 0 && latIdx < 100 && lonIdx >= 0 && lonIdx < 100 {
grid[latIdx][lonIdx] += srcData.SignalLevel
}
}
}
heatmapData["grid"] = grid
heatmapData["bounds"] = map[string]float64{
"minLat": minLat,
"maxLat": maxLat,
"minLon": minLon,
"maxLon": maxLon,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(heatmapData)
}
// handleWebSocket manages WebSocket connections for real-time aircraft data streaming.
//
// This handler:
// 1. Upgrades the HTTP connection to WebSocket protocol
// 2. Registers the client for broadcast updates
// 3. Sends initial data snapshot to the client
// 4. Handles client messages (currently just ping/pong for keepalive)
// 5. Cleans up the connection when the client disconnects
//
// WebSocket clients receive periodic updates with current aircraft positions,
// source status, and system statistics. The connection is kept alive until
// the client disconnects or the server shuts down.
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
defer conn.Close()
// Register client
s.wsClientsMu.Lock()
s.wsClients[conn] = true
s.wsClientsMu.Unlock()
// Send initial data
s.sendInitialData(conn)
// Handle client messages (ping/pong)
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
// Unregister client
s.wsClientsMu.Lock()
delete(s.wsClients, conn)
s.wsClientsMu.Unlock()
}
// sendInitialData sends a complete data snapshot to a newly connected WebSocket client.
//
// This includes:
// - All currently tracked aircraft with their state information
// - Status of all configured data sources
// - Current system statistics
//
// ICAO addresses are converted to hex strings for consistent JSON representation.
// This initial data allows the client to immediately display current aircraft
// without waiting for the next periodic update.
// getActiveClientCount returns the number of currently connected WebSocket clients.
func (s *Server) getActiveClientCount() int {
s.wsClientsMu.RLock()
defer s.wsClientsMu.RUnlock()
return len(s.wsClients)
}
// addServerStats adds server-specific statistics to the merger stats.
func (s *Server) addServerStats(stats map[string]interface{}) map[string]interface{} {
stats["active_clients"] = s.getActiveClientCount()
return stats
}
func (s *Server) sendInitialData(conn *websocket.Conn) {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.addServerStats(s.merger.GetStatistics())
// Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
msg := WebSocketMessage{
Type: "initial_data",
Timestamp: time.Now().Unix(),
Data: update,
}
conn.WriteJSON(msg)
}
// broadcastRoutine runs in a dedicated goroutine to distribute WebSocket messages.
//
// This routine:
// - Listens for broadcast messages on the broadcastChan
// - Sends messages to all connected WebSocket clients with write timeouts
// - Handles client connection cleanup on write errors
// - Respects the shutdown signal from stopChan
//
// ENHANCED: Added write timeouts and better error handling to prevent
// slow clients from blocking updates to other clients.
func (s *Server) broadcastRoutine() {
for {
select {
case <-s.stopChan:
return
case data := <-s.broadcastChan:
s.wsClientsMu.Lock()
// Create list of clients to remove (to avoid modifying map during iteration)
var toRemove []*websocket.Conn
for conn := range s.wsClients {
// Set write timeout to prevent slow clients from blocking
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
// Mark for removal but don't modify map during iteration
toRemove = append(toRemove, conn)
}
}
// Clean up failed connections
for _, conn := range toRemove {
conn.Close()
delete(s.wsClients, conn)
}
s.wsClientsMu.Unlock()
}
}
}
// periodicUpdateRoutine generates regular WebSocket updates for all connected clients.
//
// Updates are sent every second and include:
// - Current aircraft positions and state
// - Data source status updates
// - Fresh system statistics
//
// The routine uses a ticker for consistent timing and respects the shutdown
// signal. Updates are queued through broadcastUpdate() which handles the
// actual message formatting and distribution.
func (s *Server) periodicUpdateRoutine() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
return
case <-ticker.C:
s.broadcastUpdate()
}
}
}
// broadcastUpdate creates and queues an aircraft update message for WebSocket clients.
//
// This function:
// 1. Collects current aircraft data from the merger
// 2. Filters aircraft to only include "useful" ones (with position or callsign)
// 3. Formats the data as a WebSocketMessage with type "aircraft_update"
// 4. Converts ICAO addresses to hex strings for JSON compatibility
// 5. Queues the message for broadcast (blocking with timeout)
//
// IMPORTANT: Changed from non-blocking to blocking with timeout to prevent
// dropping aircraft track updates when the channel is busy.
func (s *Server) broadcastUpdate() {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.addServerStats(s.merger.GetStatistics())
// Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
msg := WebSocketMessage{
Type: "aircraft_update",
Timestamp: time.Now().Unix(),
Data: update,
}
if data, err := json.Marshal(msg); err == nil {
// Use timeout to prevent indefinite blocking while ensuring updates aren't dropped
timeout := time.After(100 * time.Millisecond)
select {
case s.broadcastChan <- data:
// Successfully queued
case <-timeout:
// Log dropped updates for debugging
log.Printf("WARNING: Broadcast channel full, dropping update with %d aircraft", len(aircraftMap))
}
}
}
// handleIndex serves the main application page at the root URL.
// Returns the embedded index.html file which contains the aircraft tracking interface.
//
// Returns 404 if the index.html file is not found in the embedded assets.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
data, err := s.staticFiles.ReadFile("static/index.html")
if err != nil {
http.Error(w, "Page not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
}
// handleFavicon serves the favicon.ico file for browser tab icons.
// Returns the embedded favicon file with appropriate content-type header.
//
// Returns 404 if the favicon.ico file is not found in the embedded assets.
func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
data, err := s.staticFiles.ReadFile("static/favicon.ico")
if err != nil {
http.Error(w, "Favicon not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/x-icon")
w.Write(data)
}
// staticFileHandler creates an HTTP handler for serving embedded static files.
//
// This handler:
// - Maps URL paths from /static/* to embedded file paths
// - Sets appropriate Content-Type headers based on file extension
// - Adds cache control headers for client-side caching (1 hour)
// - Returns 404 for missing files
//
// The handler serves files from the embedded filesystem, enabling
// single-binary deployment without external static file dependencies.
func (s *Server) staticFileHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Remove /static/ prefix from URL path to get the actual file path
filePath := "static" + r.URL.Path[len("/static"):]
data, err := s.staticFiles.ReadFile(filePath)
if err != nil {
http.NotFound(w, r)
return
}
// Set content type
ext := path.Ext(filePath)
contentType := getContentType(ext)
w.Header().Set("Content-Type", contentType)
// Cache control
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
})
}
// getContentType returns the appropriate MIME type for a file extension.
// Supports common web file types used in the SkyView interface:
// - HTML, CSS, JavaScript files
// - JSON data files
// - Image formats (SVG, PNG, JPEG, ICO)
//
// Returns "application/octet-stream" for unknown extensions.
func getContentType(ext string) string {
switch ext {
case ".html":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".ico":
return "image/x-icon"
default:
return "application/octet-stream"
}
}
// enableCORS wraps an HTTP handler with Cross-Origin Resource Sharing headers.
//
// This middleware:
// - Allows requests from any origin (*)
// - Supports GET, POST, PUT, DELETE, and OPTIONS methods
// - Permits Content-Type and Authorization headers
// - Handles preflight OPTIONS requests
//
// CORS is enabled to support web applications hosted on different domains
// than the SkyView server, which is common in development and some deployment scenarios.
func (s *Server) enableCORS(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
handler.ServeHTTP(w, r)
})
}
// handleDebugAircraft serves the /api/debug/aircraft endpoint.
// Returns all aircraft (filtered and unfiltered) for debugging position issues.
func (s *Server) handleDebugAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
// All aircraft (unfiltered)
allAircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
allAircraftMap[fmt.Sprintf("%06X", icao)] = state
}
// Filtered aircraft (useful ones)
filteredAircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
if s.isAircraftUseful(state) {
filteredAircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"all_aircraft": allAircraftMap,
"filtered_aircraft": filteredAircraftMap,
"all_count": len(allAircraftMap),
"filtered_count": len(filteredAircraftMap),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleDebugWebSocket serves the /api/debug/websocket endpoint.
// Returns WebSocket connection statistics for debugging connection issues.
func (s *Server) handleDebugWebSocket(w http.ResponseWriter, r *http.Request) {
s.wsClientsMu.RLock()
clientCount := len(s.wsClients)
s.wsClientsMu.RUnlock()
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"connected_clients": clientCount,
"broadcast_chan_len": len(s.broadcastChan),
"broadcast_chan_cap": cap(s.broadcastChan),
"broadcast_chan_full": len(s.broadcastChan) == cap(s.broadcastChan),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetDatabaseStatus returns database status and statistics
func (s *Server) handleGetDatabaseStatus(w http.ResponseWriter, r *http.Request) {
if s.database == nil {
http.Error(w, "Database not available", http.StatusServiceUnavailable)
return
}
response := make(map[string]interface{})
// Get database path and size information
dbConfig := s.database.GetConfig()
dbPath := dbConfig.Path
response["path"] = dbPath
// Get file size and modification time
if stat, err := os.Stat(dbPath); err == nil {
response["size_bytes"] = stat.Size()
response["size_mb"] = float64(stat.Size()) / (1024 * 1024)
response["modified"] = stat.ModTime().Unix()
}
// Get optimization statistics
optimizer := database.NewOptimizationManager(s.database, dbConfig)
if optimizationStats, err := optimizer.GetOptimizationStats(); err == nil {
response["efficiency_percent"] = optimizationStats.Efficiency
response["page_size"] = optimizationStats.PageSize
response["page_count"] = optimizationStats.PageCount
response["used_pages"] = optimizationStats.UsedPages
response["free_pages"] = optimizationStats.FreePages
response["auto_vacuum_enabled"] = optimizationStats.AutoVacuumEnabled
if !optimizationStats.LastVacuum.IsZero() {
response["last_vacuum"] = optimizationStats.LastVacuum.Unix()
}
}
// Get history statistics
historyStats, err := s.database.GetHistoryManager().GetStatistics()
if err != nil {
log.Printf("Error getting history statistics: %v", err)
historyStats = make(map[string]interface{})
}
// Get callsign statistics if available
callsignStats := make(map[string]interface{})
if callsignManager := s.database.GetCallsignManager(); callsignManager != nil {
stats, err := callsignManager.GetCacheStats()
if err != nil {
log.Printf("Error getting callsign statistics: %v", err)
} else {
callsignStats = stats
}
}
// Get record counts for reference data
var airportCount, airlineCount int
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airports`).Scan(&airportCount)
s.database.GetConnection().QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&airlineCount)
referenceData := make(map[string]interface{})
referenceData["airports"] = airportCount
referenceData["airlines"] = airlineCount
response["database_available"] = true
response["path"] = dbPath
response["reference_data"] = referenceData
response["history"] = historyStats
response["callsign"] = callsignStats
response["timestamp"] = time.Now().Unix()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetDataSources returns information about loaded external data sources
func (s *Server) handleGetDataSources(w http.ResponseWriter, r *http.Request) {
if s.database == nil {
http.Error(w, "Database not available", http.StatusServiceUnavailable)
return
}
// Create data loader instance
loader := database.NewDataLoader(s.database.GetConnection())
availableSources := database.GetAvailableDataSources()
loadedSources, err := loader.GetLoadedDataSources()
if err != nil {
log.Printf("Error getting loaded data sources: %v", err)
loadedSources = []database.DataSource{}
}
response := map[string]interface{}{
"available": availableSources,
"loaded": loadedSources,
"timestamp": time.Now().Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetCallsignInfo returns enriched information about a callsign
func (s *Server) handleGetCallsignInfo(w http.ResponseWriter, r *http.Request) {
if s.database == nil {
http.Error(w, "Database not available", http.StatusServiceUnavailable)
return
}
// Extract callsign from URL parameters
vars := mux.Vars(r)
callsign := vars["callsign"]
if callsign == "" {
http.Error(w, "Callsign parameter required", http.StatusBadRequest)
return
}
// Get callsign information from database
callsignInfo, err := s.database.GetCallsignManager().GetCallsignInfo(callsign)
if err != nil {
log.Printf("Error getting callsign info for %s: %v", callsign, err)
http.Error(w, "Failed to lookup callsign information", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"callsign": callsignInfo,
"timestamp": time.Now().Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// debugEmbeddedFiles lists all embedded files for debugging
func (s *Server) debugEmbeddedFiles() {
log.Println("=== Debugging Embedded Files ===")
err := fs.WalkDir(s.staticFiles, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("Error walking %s: %v", path, err)
return nil
}
if !d.IsDir() {
info, _ := d.Info()
log.Printf("Embedded file: %s (size: %d bytes)", path, info.Size())
} else {
log.Printf("Embedded dir: %s/", path)
}
return nil
})
if err != nil {
log.Printf("Error walking embedded files: %v", err)
}
log.Println("=== End Embedded Files Debug ===")
}
// handleDatabasePage serves the database status page
func (s *Server) handleDatabasePage(w http.ResponseWriter, r *http.Request) {
// Debug embedded files if debug is enabled
if s.debug {
s.debugEmbeddedFiles()
}
// Try to read the database HTML file from embedded assets
data, err := s.staticFiles.ReadFile("static/database.html")
if err != nil {
log.Printf("Error reading database.html: %v", err)
// Fallback: serve a simple HTML page with API calls
fallbackHTML := `<!DOCTYPE html>
<html><head><title>Database Status - SkyView</title></head>
<body>
<h1>Database Status</h1>
<div id="status">Loading...</div>
<script>
fetch('/api/database/status')
.then(r => r.json())
.then(data => {
document.getElementById('status').innerHTML =
'<pre>' + JSON.stringify(data, null, 2) + '</pre>';
});
</script>
</body></html>`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(fallbackHTML))
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
}