diff --git a/app/static/detail_panel.js b/app/static/detail_panel.js index ea2ef01..9137037 100644 --- a/app/static/detail_panel.js +++ b/app/static/detail_panel.js @@ -326,6 +326,9 @@ export function buildPopupContent(a, now, airports) { `` + `
First seen` + `
` + + `` + ``; injectHelpIcons(root); updatePopupContent(root, a, now, airports); @@ -605,6 +608,18 @@ export function updatePopupContent(root, a, now, airports) { const firstSeen = entry?.sessionFirstSeen || a.first_seen; q('.pop-first-seen').textContent = relativeAge(firstSeen, now); + // P2P "also seen by N peers" — surfaced only when the relay reported a + // non-zero count for this aircraft. Locally-only aircraft (e.g. P2P off, + // or no peer is currently reporting this ICAO) leave the stat hidden. + const peersStat = q('.pop-peers-stat'); + const seenN = a.seen_by_others; + if (typeof seenN === 'number' && seenN > 0) { + q('.pop-peers-val').textContent = `${seenN} peer${seenN === 1 ? '' : 's'}`; + peersStat.hidden = false; + } else { + peersStat.hidden = true; + } + renderCommBSection(root, q, a, now); } diff --git a/dotnet/src/FlightJar.Api/Endpoints/P2PEndpoints.cs b/dotnet/src/FlightJar.Api/Endpoints/P2PEndpoints.cs index 072ed45..f433d61 100644 --- a/dotnet/src/FlightJar.Api/Endpoints/P2PEndpoints.cs +++ b/dotnet/src/FlightJar.Api/Endpoints/P2PEndpoints.cs @@ -120,7 +120,10 @@ private static string Sanitise(RegistrySnapshot snap) foreach (var ac in snap.Aircraft) { if (ac.Peer == true) continue; - aircraft.Add(ac with { DistanceKm = null }); + // SeenByOthers is filled by the cloud relay per-recipient and + // doesn't make sense over a direct same-LAN /p2p/ws stream; + // strip it so consumers can't mistake it for our own count. + aircraft.Add(ac with { DistanceKm = null, SeenByOthers = null }); } var sanitised = snap with { diff --git a/dotnet/src/FlightJar.Api/Hosting/P2PRelayClientService.cs b/dotnet/src/FlightJar.Api/Hosting/P2PRelayClientService.cs index 02acf95..0b55f26 100644 --- a/dotnet/src/FlightJar.Api/Hosting/P2PRelayClientService.cs +++ b/dotnet/src/FlightJar.Api/Hosting/P2PRelayClientService.cs @@ -279,7 +279,11 @@ private string BuildSanitisedPayload() foreach (var ac in snap.Aircraft) { if (ac.Peer == true) continue; // don't echo back what we received - aircraft.Add(ac with { DistanceKm = null }); + // Strip receiver-specific + relay-computed fields: + // - DistanceKm describes our reception, not the aircraft itself. + // - SeenByOthers is filled by the relay per-recipient; if we + // echoed it back the relay would treat it as our reading. + aircraft.Add(ac with { DistanceKm = null, SeenByOthers = null }); } // Explicitly null out receiver location — never share it with the relay. diff --git a/dotnet/src/FlightJar.Core/State/PeerMerge.cs b/dotnet/src/FlightJar.Core/State/PeerMerge.cs index 01ed375..94f599e 100644 --- a/dotnet/src/FlightJar.Core/State/PeerMerge.cs +++ b/dotnet/src/FlightJar.Core/State/PeerMerge.cs @@ -106,6 +106,11 @@ public static SnapshotAircraft Combine(SnapshotAircraft local, SnapshotAircraft // Peer flag stays null — we have direct contact, so this // record renders as a local aircraft (no peer styling). Peer = null, + + // Relay-computed: how many other peers also report this ICAO. + // The relay calculated this per-recipient (excluding us), so + // we just carry the peer record's value through. + SeenByOthers = peer.SeenByOthers, }; } } diff --git a/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs b/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs index 7b65fa2..8324415 100644 --- a/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs +++ b/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs @@ -122,6 +122,14 @@ public sealed record SnapshotAircraft /// for locally-observed aircraft. public bool? Peer { get; init; } + /// How many OTHER P2P peers also see this aircraft (excluding + /// this receiver). Computed by the relay per-recipient and surfaced via + /// the aggregate broadcast — null for locally-only aircraft when the + /// relay isn't connected, or any aircraft outside the federation. The + /// detail panel renders an "also seen by N peers" stat when this is + /// > 0. + public int? SeenByOthers { get; init; } + // Enrichment fields populated by the snapshot pusher from the external // clients. Names match the wire schema the frontend reads directly: // `origin`, `destination`, `phase`, `operator`, `operator_iata`, diff --git a/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs b/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs index f36d690..1fb16aa 100644 --- a/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs +++ b/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs @@ -214,6 +214,21 @@ public void ExtendAirports_SkipsAircraftWithNullAirportInfo() Assert.Empty(airports); } + [Fact] + public void Combine_TakesSeenByOthersFromPeer() + { + // The relay computes seen_by_others per-recipient (it knows the + // contributor set) and stamps it on the peer record. Combine must + // surface that value on the merged record so the detail panel can + // render the count even when the aircraft is locally observed. + var local = new SnapshotAircraft { Icao = "abc123", Callsign = "BAW123" }; + var peer = new SnapshotAircraft { Icao = "abc123", SeenByOthers = 3 }; + + var merged = PeerMerge.Combine(local, peer); + + Assert.Equal(3, merged.SeenByOthers); + } + [Fact] public void Combine_FillsAltitudeAndVelocityFromPeer() { diff --git a/relay-worker/src/relay.ts b/relay-worker/src/relay.ts index 248a72b..a0eb4a9 100644 --- a/relay-worker/src/relay.ts +++ b/relay-worker/src/relay.ts @@ -3,6 +3,10 @@ import { DurableObject } from 'cloudflare:workers'; interface AircraftEntry { data: Record; receivedAt: number; // epoch seconds + // WebSockets that have reported this aircraft, keyed by their last + // contribution timestamp. Lets us tell each recipient how many OTHER + // peers also see the aircraft, so the UI can render "also seen by N". + contributors: Map; } interface TokenRecord { @@ -143,7 +147,7 @@ export class RelayDurableObject extends DurableObject { })); } - override webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void { + override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void { if (typeof message !== 'string') return; let parsed: Record; @@ -163,17 +167,31 @@ export class RelayDurableObject extends DurableObject { if (typeof ac !== 'object' || ac === null) continue; const icao = (ac as Record)['icao']; if (typeof icao !== 'string' || !icao) continue; - this.aggregate.set(icao.toLowerCase(), { data: ac as Record, receivedAt: now }); + const key = icao.toLowerCase(); + const existing = this.aggregate.get(key); + if (existing) { + existing.data = ac as Record; + existing.receivedAt = now; + existing.contributors.set(ws, now); + } else { + this.aggregate.set(key, { + data: ac as Record, + receivedAt: now, + contributors: new Map([[ws, now]]), + }); + } } } override webSocketClose(ws: WebSocket): void { this.connections.delete(ws); + this.dropContributor(ws); console.log(JSON.stringify({ event: 'disconnect', connections: this.connections.size })); } override webSocketError(ws: WebSocket, error: unknown): void { this.connections.delete(ws); + this.dropContributor(ws); try { ws.close(); } catch { /* already closed */ } console.log(JSON.stringify({ event: 'ws_error', @@ -182,6 +200,15 @@ export class RelayDurableObject extends DurableObject { })); } + // Remove `ws` from every aircraft's contributor map. Called on clean + // disconnect so the seen_by_others count drops immediately rather than + // waiting up to STALE_S for the lazy eviction in broadcast() to catch up. + private dropContributor(ws: WebSocket): void { + for (const entry of this.aggregate.values()) { + entry.contributors.delete(ws); + } + } + override async alarm(): Promise { this.broadcast(); // Re-schedule unless there are no connections (DO will hibernate) @@ -196,27 +223,34 @@ export class RelayDurableObject extends DurableObject { const now = Date.now() / 1000; const cutoff = now - STALE_S; - // Evict stale and collect fresh - const fresh: Record[] = []; + // Evict stale aggregate entries; for each surviving entry also sweep + // out per-WS contributor timestamps that have aged past the same + // cutoff, so seen_by_others counts a contributor only as long as + // they're actively reporting the aircraft. + const fresh: AircraftEntry[] = []; for (const [icao, entry] of this.aggregate) { if (entry.receivedAt < cutoff) { this.aggregate.delete(icao); - } else { - fresh.push(entry.data); + continue; + } + for (const [ws, ts] of entry.contributors) { + if (ts < cutoff) entry.contributors.delete(ws); } + fresh.push(entry); } - if (fresh.length === 0 && this.connections.size === 0) return; - - // Count of "other" peers from any single recipient's perspective — - // every connected client is one of `connections`, so each sees - // `connections.size - 1` others. Same number for every recipient, - // so one shared payload is correct. + // Total connected count goes in the envelope; per-aircraft counts go + // in `seen_by_others` and are computed per-recipient (each WS subtracts + // itself from every aircraft it's contributing to). const peers = Math.max(0, this.connections.size - 1); - const payload = JSON.stringify({ type: 'aggregate', peers, aircraft: fresh }); const dead: WebSocket[] = []; for (const ws of this.connections) { + const aircraft = fresh.map(entry => ({ + ...entry.data, + seen_by_others: entry.contributors.size - (entry.contributors.has(ws) ? 1 : 0), + })); + const payload = JSON.stringify({ type: 'aggregate', peers, aircraft }); try { ws.send(payload); } catch { @@ -226,6 +260,7 @@ export class RelayDurableObject extends DurableObject { for (const ws of dead) { this.connections.delete(ws); + this.dropContributor(ws); try { ws.close(); } catch { /* already closed */ } } }