Initial implementation of SkyView - ADS-B aircraft tracker
- Go application with embedded static files for dump1090 frontend - TCP client for SBS-1/BaseStation format (port 30003) - Real-time WebSocket updates with aircraft tracking - Modern web frontend with Leaflet maps and mobile-responsive design - Aircraft table with filtering/sorting and statistics dashboard - Origin configuration for receiver location and distance calculations - Automatic config.json loading from current directory - Foreground execution by default with optional -daemon flag 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
8ce4f4c397
19 changed files with 1971 additions and 0 deletions
210
internal/server/server.go
Normal file
210
internal/server/server.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"skyview/internal/client"
|
||||
"skyview/internal/config"
|
||||
"skyview/internal/parser"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
staticFiles embed.FS
|
||||
upgrader websocket.Upgrader
|
||||
wsClients map[*websocket.Conn]bool
|
||||
wsClientsMux sync.RWMutex
|
||||
dump1090 *client.Dump1090Client
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, staticFiles embed.FS, ctx context.Context) http.Handler {
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
staticFiles: staticFiles,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
wsClients: make(map[*websocket.Conn]bool),
|
||||
dump1090: client.NewDump1090Client(cfg),
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
if err := s.dump1090.Start(ctx); err != nil {
|
||||
log.Printf("Failed to start dump1090 client: %v", err)
|
||||
}
|
||||
|
||||
go s.subscribeToAircraftUpdates()
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.HandleFunc("/", s.serveIndex).Methods("GET")
|
||||
router.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
|
||||
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
apiRouter.HandleFunc("/aircraft", s.getAircraft).Methods("GET")
|
||||
apiRouter.HandleFunc("/stats", s.getStats).Methods("GET")
|
||||
apiRouter.HandleFunc("/config", s.getConfig).Methods("GET")
|
||||
|
||||
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFiles))))
|
||||
|
||||
return s.enableCORS(router)
|
||||
}
|
||||
|
||||
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.staticFiles.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) getAircraft(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.dump1090.GetAircraftData()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"now": data.Now,
|
||||
"messages": data.Messages,
|
||||
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.dump1090.GetAircraftData()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total": map[string]interface{}{
|
||||
"aircraft": len(data.Aircraft),
|
||||
"messages": map[string]interface{}{
|
||||
"total": data.Messages,
|
||||
"last1min": data.Messages,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configData := map[string]interface{}{
|
||||
"origin": map[string]interface{}{
|
||||
"latitude": s.config.Origin.Latitude,
|
||||
"longitude": s.config.Origin.Longitude,
|
||||
"name": s.config.Origin.Name,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(configData)
|
||||
}
|
||||
|
||||
func (s *Server) aircraftMapToSlice(aircraftMap map[string]parser.Aircraft) []parser.Aircraft {
|
||||
aircraft := make([]parser.Aircraft, 0, len(aircraftMap))
|
||||
for _, a := range aircraftMap {
|
||||
aircraft = append(aircraft, a)
|
||||
}
|
||||
return aircraft
|
||||
}
|
||||
|
||||
func (s *Server) subscribeToAircraftUpdates() {
|
||||
updates := s.dump1090.Subscribe()
|
||||
|
||||
for data := range updates {
|
||||
message := WebSocketMessage{
|
||||
Type: "aircraft_update",
|
||||
Data: map[string]interface{}{
|
||||
"now": data.Now,
|
||||
"messages": data.Messages,
|
||||
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||
},
|
||||
}
|
||||
|
||||
s.broadcastToWebSocketClients(message)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
s.wsClientsMux.Lock()
|
||||
s.wsClients[conn] = true
|
||||
s.wsClientsMux.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.wsClientsMux.Lock()
|
||||
delete(s.wsClients, conn)
|
||||
s.wsClientsMux.Unlock()
|
||||
}()
|
||||
|
||||
data := s.dump1090.GetAircraftData()
|
||||
initialMessage := WebSocketMessage{
|
||||
Type: "aircraft_update",
|
||||
Data: map[string]interface{}{
|
||||
"now": data.Now,
|
||||
"messages": data.Messages,
|
||||
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||
},
|
||||
}
|
||||
conn.WriteJSON(initialMessage)
|
||||
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) broadcastToWebSocketClients(message WebSocketMessage) {
|
||||
s.wsClientsMux.RLock()
|
||||
defer s.wsClientsMux.RUnlock()
|
||||
|
||||
for client := range s.wsClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
client.Close()
|
||||
delete(s.wsClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
55
internal/server/server_test.go
Normal file
55
internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"skyview/internal/config"
|
||||
)
|
||||
|
||||
//go:embed testdata/*
|
||||
var testStaticFiles embed.FS
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Address: ":8080",
|
||||
Port: 8080,
|
||||
},
|
||||
Dump1090: config.Dump1090Config{
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
URL: "http://localhost:8080",
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg, testStaticFiles)
|
||||
if handler == nil {
|
||||
t.Fatal("Expected handler to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Dump1090: config.Dump1090Config{
|
||||
URL: "http://localhost:8080",
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg, testStaticFiles)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/api/aircraft", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Errorf("Expected CORS header, got %s", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
5
internal/server/testdata/test.html
vendored
Normal file
5
internal/server/testdata/test.html
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body><h1>Test</h1></body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue