Add active WebSocket client tracking and display

Track and display the number of active WebSocket connections viewing the
aircraft tracker. Features:

Backend:
- Add getActiveClientCount() method for thread-safe client counting
- Include active_clients count in all statistics responses
- Integrate with existing stats API endpoints

Frontend:
- Display "X viewer(s)" in header stats with proper plural/singular grammar
- Add "Active Viewers" card to Statistics view
- Real-time updates as clients connect/disconnect
- Fallback to 1 when stats not yet available

This adds a social element showing "Now being watched by X users" style
information prominently in the interface.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-24 21:56:16 +02:00
commit 6414ea72f1
3 changed files with 34 additions and 4 deletions

View file

@ -54,6 +54,7 @@
<div class="stats-summary"> <div class="stats-summary">
<span id="aircraft-count">0 aircraft</span> <span id="aircraft-count">0 aircraft</span>
<span id="sources-count">0 sources</span> <span id="sources-count">0 sources</span>
<span id="active-clients">1 viewer</span>
<span id="connection-status" class="connection-status disconnected">Connecting...</span> <span id="connection-status" class="connection-status disconnected">Connecting...</span>
</div> </div>
</header> </header>
@ -195,6 +196,10 @@
<h3>Active Sources</h3> <h3>Active Sources</h3>
<div class="stat-value" id="active-sources">0</div> <div class="stat-value" id="active-sources">0</div>
</div> </div>
<div class="stat-card">
<h3>Active Viewers</h3>
<div class="stat-value" id="active-viewers">1</div>
</div>
<div class="stat-card"> <div class="stat-card">
<h3>Messages/sec</h3> <h3>Messages/sec</h3>
<div class="stat-value" id="messages-sec">0</div> <div class="stat-value" id="messages-sec">0</div>

View file

@ -241,6 +241,7 @@ export class UIManager {
updateStatistics() { updateStatistics() {
const totalAircraftEl = document.getElementById('total-aircraft'); const totalAircraftEl = document.getElementById('total-aircraft');
const activeSourcesEl = document.getElementById('active-sources'); const activeSourcesEl = document.getElementById('active-sources');
const activeViewersEl = document.getElementById('active-viewers');
const maxRangeEl = document.getElementById('max-range'); const maxRangeEl = document.getElementById('max-range');
const messagesSecEl = document.getElementById('messages-sec'); const messagesSecEl = document.getElementById('messages-sec');
@ -248,6 +249,9 @@ export class UIManager {
if (activeSourcesEl) { if (activeSourcesEl) {
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length; activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
} }
if (activeViewersEl) {
activeViewersEl.textContent = this.stats.active_clients || 1;
}
// Calculate max range // Calculate max range
let maxDistance = 0; let maxDistance = 0;
@ -267,10 +271,18 @@ export class UIManager {
updateHeaderInfo() { updateHeaderInfo() {
const aircraftCountEl = document.getElementById('aircraft-count'); const aircraftCountEl = document.getElementById('aircraft-count');
const sourcesCountEl = document.getElementById('sources-count'); const sourcesCountEl = document.getElementById('sources-count');
const activeClientsEl = document.getElementById('active-clients');
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`; if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`; if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
// Update active clients count
const clientCount = this.stats.active_clients || 1;
if (activeClientsEl) {
const clientText = clientCount === 1 ? 'viewer' : 'viewers';
activeClientsEl.textContent = `${clientCount} ${clientText}`;
}
this.updateClocks(); this.updateClocks();
} }

View file

@ -257,7 +257,7 @@ func (s *Server) isAircraftUseful(aircraft *merger.AircraftState) bool {
// - 503 Service Unavailable when the service is degraded (no active sources) // - 503 Service Unavailable when the service is degraded (no active sources)
func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
sources := s.merger.GetSources() sources := s.merger.GetSources()
stats := s.merger.GetStatistics() stats := s.addServerStats(s.merger.GetStatistics())
aircraft := s.merger.GetAircraft() aircraft := s.merger.GetAircraft()
// Check if we have any active sources // Check if we have any active sources
@ -393,7 +393,7 @@ func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
// //
// The exact statistics depend on the merger implementation. // The exact statistics depend on the merger implementation.
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
stats := s.merger.GetStatistics() stats := s.addServerStats(s.merger.GetStatistics())
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats) json.NewEncoder(w).Encode(stats)
@ -576,10 +576,23 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// ICAO addresses are converted to hex strings for consistent JSON representation. // ICAO addresses are converted to hex strings for consistent JSON representation.
// This initial data allows the client to immediately display current aircraft // This initial data allows the client to immediately display current aircraft
// without waiting for the next periodic update. // 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) { func (s *Server) sendInitialData(conn *websocket.Conn) {
aircraft := s.merger.GetAircraft() aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources() sources := s.merger.GetSources()
stats := s.merger.GetStatistics() stats := s.addServerStats(s.merger.GetStatistics())
// Convert ICAO keys to hex strings and filter useful aircraft // Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState) aircraftMap := make(map[string]*merger.AircraftState)
@ -671,7 +684,7 @@ func (s *Server) periodicUpdateRoutine() {
func (s *Server) broadcastUpdate() { func (s *Server) broadcastUpdate() {
aircraft := s.merger.GetAircraft() aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources() sources := s.merger.GetSources()
stats := s.merger.GetStatistics() stats := s.addServerStats(s.merger.GetStatistics())
// Convert ICAO keys to hex strings and filter useful aircraft // Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState) aircraftMap := make(map[string]*merger.AircraftState)