Skip to content

Commit b580bb0

Browse files
MrSuttonmannclaude
andcommitted
Show per-aircraft peer count in detail panel
Relay tracks a contributor WebSocket set per ICAO and emits seen_by_others per-recipient in each aggregate broadcast (subtracting the recipient from the count). The .NET side carries it through SnapshotAircraft + PeerMerge.Combine, and strips it from both outbound P2P paths so a relay-computed value is never echoed back. The detail panel surfaces "Also seen by N peer(s)" alongside the existing stats, hidden when zero or absent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ed0daf commit b580bb0

7 files changed

Lines changed: 100 additions & 15 deletions

File tree

app/static/detail_panel.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ export function buildPopupContent(a, now, airports) {
326326
`<span class="stat-val pop-signal"></span></div>` +
327327
`<div class="stat"><span class="stat-label">First seen</span>` +
328328
`<span class="stat-val pop-first-seen"></span></div>` +
329+
`<div class="stat pop-peers-stat" hidden>` +
330+
`<span class="stat-label">Also seen by</span>` +
331+
`<span class="stat-val pop-peers-val"></span></div>` +
329332
`</div>`;
330333
injectHelpIcons(root);
331334
updatePopupContent(root, a, now, airports);
@@ -605,6 +608,18 @@ export function updatePopupContent(root, a, now, airports) {
605608
const firstSeen = entry?.sessionFirstSeen || a.first_seen;
606609
q('.pop-first-seen').textContent = relativeAge(firstSeen, now);
607610

611+
// P2P "also seen by N peers" — surfaced only when the relay reported a
612+
// non-zero count for this aircraft. Locally-only aircraft (e.g. P2P off,
613+
// or no peer is currently reporting this ICAO) leave the stat hidden.
614+
const peersStat = q('.pop-peers-stat');
615+
const seenN = a.seen_by_others;
616+
if (typeof seenN === 'number' && seenN > 0) {
617+
q('.pop-peers-val').textContent = `${seenN} peer${seenN === 1 ? '' : 's'}`;
618+
peersStat.hidden = false;
619+
} else {
620+
peersStat.hidden = true;
621+
}
622+
608623
renderCommBSection(root, q, a, now);
609624
}
610625

dotnet/src/FlightJar.Api/Endpoints/P2PEndpoints.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ private static string Sanitise(RegistrySnapshot snap)
120120
foreach (var ac in snap.Aircraft)
121121
{
122122
if (ac.Peer == true) continue;
123-
aircraft.Add(ac with { DistanceKm = null });
123+
// SeenByOthers is filled by the cloud relay per-recipient and
124+
// doesn't make sense over a direct same-LAN /p2p/ws stream;
125+
// strip it so consumers can't mistake it for our own count.
126+
aircraft.Add(ac with { DistanceKm = null, SeenByOthers = null });
124127
}
125128
var sanitised = snap with
126129
{

dotnet/src/FlightJar.Api/Hosting/P2PRelayClientService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ private string BuildSanitisedPayload()
279279
foreach (var ac in snap.Aircraft)
280280
{
281281
if (ac.Peer == true) continue; // don't echo back what we received
282-
aircraft.Add(ac with { DistanceKm = null });
282+
// Strip receiver-specific + relay-computed fields:
283+
// - DistanceKm describes our reception, not the aircraft itself.
284+
// - SeenByOthers is filled by the relay per-recipient; if we
285+
// echoed it back the relay would treat it as our reading.
286+
aircraft.Add(ac with { DistanceKm = null, SeenByOthers = null });
283287
}
284288

285289
// Explicitly null out receiver location — never share it with the relay.

dotnet/src/FlightJar.Core/State/PeerMerge.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ public static SnapshotAircraft Combine(SnapshotAircraft local, SnapshotAircraft
106106
// Peer flag stays null — we have direct contact, so this
107107
// record renders as a local aircraft (no peer styling).
108108
Peer = null,
109+
110+
// Relay-computed: how many other peers also report this ICAO.
111+
// The relay calculated this per-recipient (excluding us), so
112+
// we just carry the peer record's value through.
113+
SeenByOthers = peer.SeenByOthers,
109114
};
110115
}
111116
}

dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ public sealed record SnapshotAircraft
122122
/// for locally-observed aircraft.</summary>
123123
public bool? Peer { get; init; }
124124

125+
/// <summary>How many OTHER P2P peers also see this aircraft (excluding
126+
/// this receiver). Computed by the relay per-recipient and surfaced via
127+
/// the aggregate broadcast — null for locally-only aircraft when the
128+
/// relay isn't connected, or any aircraft outside the federation. The
129+
/// detail panel renders an "also seen by N peers" stat when this is
130+
/// > 0.</summary>
131+
public int? SeenByOthers { get; init; }
132+
125133
// Enrichment fields populated by the snapshot pusher from the external
126134
// clients. Names match the wire schema the frontend reads directly:
127135
// `origin`, `destination`, `phase`, `operator`, `operator_iata`,

dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,21 @@ public void ExtendAirports_SkipsAircraftWithNullAirportInfo()
214214
Assert.Empty(airports);
215215
}
216216

217+
[Fact]
218+
public void Combine_TakesSeenByOthersFromPeer()
219+
{
220+
// The relay computes seen_by_others per-recipient (it knows the
221+
// contributor set) and stamps it on the peer record. Combine must
222+
// surface that value on the merged record so the detail panel can
223+
// render the count even when the aircraft is locally observed.
224+
var local = new SnapshotAircraft { Icao = "abc123", Callsign = "BAW123" };
225+
var peer = new SnapshotAircraft { Icao = "abc123", SeenByOthers = 3 };
226+
227+
var merged = PeerMerge.Combine(local, peer);
228+
229+
Assert.Equal(3, merged.SeenByOthers);
230+
}
231+
217232
[Fact]
218233
public void Combine_FillsAltitudeAndVelocityFromPeer()
219234
{

relay-worker/src/relay.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { DurableObject } from 'cloudflare:workers';
33
interface AircraftEntry {
44
data: Record<string, unknown>;
55
receivedAt: number; // epoch seconds
6+
// WebSockets that have reported this aircraft, keyed by their last
7+
// contribution timestamp. Lets us tell each recipient how many OTHER
8+
// peers also see the aircraft, so the UI can render "also seen by N".
9+
contributors: Map<WebSocket, number>;
610
}
711

812
interface TokenRecord {
@@ -143,7 +147,7 @@ export class RelayDurableObject extends DurableObject {
143147
}));
144148
}
145149

146-
override webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void {
150+
override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
147151
if (typeof message !== 'string') return;
148152

149153
let parsed: Record<string, unknown>;
@@ -163,17 +167,31 @@ export class RelayDurableObject extends DurableObject {
163167
if (typeof ac !== 'object' || ac === null) continue;
164168
const icao = (ac as Record<string, unknown>)['icao'];
165169
if (typeof icao !== 'string' || !icao) continue;
166-
this.aggregate.set(icao.toLowerCase(), { data: ac as Record<string, unknown>, receivedAt: now });
170+
const key = icao.toLowerCase();
171+
const existing = this.aggregate.get(key);
172+
if (existing) {
173+
existing.data = ac as Record<string, unknown>;
174+
existing.receivedAt = now;
175+
existing.contributors.set(ws, now);
176+
} else {
177+
this.aggregate.set(key, {
178+
data: ac as Record<string, unknown>,
179+
receivedAt: now,
180+
contributors: new Map([[ws, now]]),
181+
});
182+
}
167183
}
168184
}
169185

170186
override webSocketClose(ws: WebSocket): void {
171187
this.connections.delete(ws);
188+
this.dropContributor(ws);
172189
console.log(JSON.stringify({ event: 'disconnect', connections: this.connections.size }));
173190
}
174191

175192
override webSocketError(ws: WebSocket, error: unknown): void {
176193
this.connections.delete(ws);
194+
this.dropContributor(ws);
177195
try { ws.close(); } catch { /* already closed */ }
178196
console.log(JSON.stringify({
179197
event: 'ws_error',
@@ -182,6 +200,15 @@ export class RelayDurableObject extends DurableObject {
182200
}));
183201
}
184202

203+
// Remove `ws` from every aircraft's contributor map. Called on clean
204+
// disconnect so the seen_by_others count drops immediately rather than
205+
// waiting up to STALE_S for the lazy eviction in broadcast() to catch up.
206+
private dropContributor(ws: WebSocket): void {
207+
for (const entry of this.aggregate.values()) {
208+
entry.contributors.delete(ws);
209+
}
210+
}
211+
185212
override async alarm(): Promise<void> {
186213
this.broadcast();
187214
// Re-schedule unless there are no connections (DO will hibernate)
@@ -196,27 +223,34 @@ export class RelayDurableObject extends DurableObject {
196223
const now = Date.now() / 1000;
197224
const cutoff = now - STALE_S;
198225

199-
// Evict stale and collect fresh
200-
const fresh: Record<string, unknown>[] = [];
226+
// Evict stale aggregate entries; for each surviving entry also sweep
227+
// out per-WS contributor timestamps that have aged past the same
228+
// cutoff, so seen_by_others counts a contributor only as long as
229+
// they're actively reporting the aircraft.
230+
const fresh: AircraftEntry[] = [];
201231
for (const [icao, entry] of this.aggregate) {
202232
if (entry.receivedAt < cutoff) {
203233
this.aggregate.delete(icao);
204-
} else {
205-
fresh.push(entry.data);
234+
continue;
235+
}
236+
for (const [ws, ts] of entry.contributors) {
237+
if (ts < cutoff) entry.contributors.delete(ws);
206238
}
239+
fresh.push(entry);
207240
}
208241

209-
if (fresh.length === 0 && this.connections.size === 0) return;
210-
211-
// Count of "other" peers from any single recipient's perspective —
212-
// every connected client is one of `connections`, so each sees
213-
// `connections.size - 1` others. Same number for every recipient,
214-
// so one shared payload is correct.
242+
// Total connected count goes in the envelope; per-aircraft counts go
243+
// in `seen_by_others` and are computed per-recipient (each WS subtracts
244+
// itself from every aircraft it's contributing to).
215245
const peers = Math.max(0, this.connections.size - 1);
216-
const payload = JSON.stringify({ type: 'aggregate', peers, aircraft: fresh });
217246
const dead: WebSocket[] = [];
218247

219248
for (const ws of this.connections) {
249+
const aircraft = fresh.map(entry => ({
250+
...entry.data,
251+
seen_by_others: entry.contributors.size - (entry.contributors.has(ws) ? 1 : 0),
252+
}));
253+
const payload = JSON.stringify({ type: 'aggregate', peers, aircraft });
220254
try {
221255
ws.send(payload);
222256
} catch {
@@ -226,6 +260,7 @@ export class RelayDurableObject extends DurableObject {
226260

227261
for (const ws of dead) {
228262
this.connections.delete(ws);
263+
this.dropContributor(ws);
229264
try { ws.close(); } catch { /* already closed */ }
230265
}
231266
}

0 commit comments

Comments
 (0)