Name the reporting provider in Contacts and Cockpit counts

The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol,
AISStream) while the Layers panel and Contacts named the provider the last
snapshot reported, and the vessels layer kept that name out of getStats(),
so Contacts always said AISStream. Each cohort now carries the provider and
whether it answered as a fallback or from a stale snapshot, and both views
show it the way the Layers row does ("adsb.lol · FALLBACK").

Fixes HAL-721.
This commit is contained in:
German Khokhlov committed 2026-11-03 16:26:55 +04:00
1 parent f3adccd01a
commit 415f23739f
15 files changed
+191 -6

No files matched your search

+1
View File
@@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
+7
View File
@@ -871,6 +871,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -1713,6 +1714,10 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -3208,6 +3213,8 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
+12
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// Whether the provider answered as a fallback or from a stale snapshot.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(sourceState.stats);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+14
View File
@@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
// HAL-721: Contacts named a fixed AIS provider while this layer showed the
// one its server reported.
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// The provider the last snapshot named, as flights and military report it.
source: layer.source,
};
},
};
+3
View File
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { sourceStatusLabel } from './layerNames.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -110,6 +111,8 @@ export function updateContext(info, heading) {
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
// The provider that answered, named as the Layers panel names it.
shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus);
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
+46
View File
@@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
@@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) =>
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
// HAL-721: the counters named fixed providers while the layer and the
// Contacts list named the one that answered.
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
+4 -1
View File
@@ -25,7 +25,10 @@ export function createContextState() {
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
COCKPIT_CONTEXT_COHORTS.map((id) => [
id,
{ count: '—', unknown: false, source: '—' },
]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
+14
View File
@@ -137,3 +137,17 @@ export function sourceLabel(source) {
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* A provider name with the feed state it answered in, so a fallback or stale
* snapshot does not read as the live primary feed.
* @param {string} source English source name.
* @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`.
* @returns {string}
*/
export function sourceStatusLabel(source, status) {
const name = sourceLabel(source);
if (status === 'fallback') return `${name} · ${t('FALLBACK')}`;
if (status === 'stale') return `${name} · ${t('STALE')}`;
return name;
}
+17
View File
@@ -7,6 +7,7 @@ import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
sourceStatusLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
@@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a provider name carries the fallback or stale state it answered in', () => {
assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder');
assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK');
assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE');
setLocale('ru');
try {
assert.equal(
sourceStatusLabel('adsb.lol', 'fallback'),
`adsb.lol · ${t('FALLBACK')}`,
);
assert.notEqual(t('FALLBACK'), 'FALLBACK');
} finally {
setLocale('en');
}
});
+4 -4
View File
@@ -49,10 +49,10 @@ const weatherLabel = computed(() =>
<span class="cockpit-context-qualifier">{{ t('CONTEXT ONLY') }}</span>
</header>
<div class="cockpit-context-cohorts" role="list" :aria-label="t('Nearby cohort counts')">
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no" :title="c.cohorts.flights.source">{{ c.cohorts.flights.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no" :title="c.cohorts.military.source">{{ c.cohorts.military.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no" :title="c.cohorts['ais-live-vessels'].source">{{ c.cohorts['ais-live-vessels'].source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no" :title="c.cohorts['military-installations'].source">{{ c.cohorts['military-installations'].source }}</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>