From 415f23739f7c81dc9f798fb5107f0e9e1044b2bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:26:55 +0400 Subject: [PATCH] Name the reporting provider in Contacts and Cockpit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 7 ++++ src/layers/awareness/model.js | 1 + src/layers/awareness/panel.js | 3 +- src/layers/awareness/panel.test.mjs | 38 +++++++++++++++++++++ src/layers/awareness/queries.test.mjs | 26 +++++++++++++++ src/layers/awareness/subject.js | 12 +++++++ src/layers/vessels/ownership.test.mjs | 14 ++++++++ src/layers/vessels/queries.js | 2 ++ src/ui/cockpitContext.js | 3 ++ src/ui/cockpitContext.test.mjs | 46 ++++++++++++++++++++++++++ src/ui/cockpitContextState.js | 5 ++- src/ui/layerNames.js | 14 ++++++++ src/ui/layerNames.test.mjs | 17 ++++++++++ src/ui/views/panels/CockpitContext.vue | 8 ++--- 15 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/layers/awareness/panel.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -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, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -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, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -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?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -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); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -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', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -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'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -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 }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -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); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -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 }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -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; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -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'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}
-
{{ t('FLT') }}{{ c.cohorts.flights.count }}OpenSky
-
{{ t('MIL') }}{{ c.cohorts.military.count }}adsb.lol
-
AIS{{ c.cohorts['ais-live-vessels'].count }}AISStream
-
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}OpenStreetMap
+
{{ t('FLT') }}{{ c.cohorts.flights.count }}{{ c.cohorts.flights.source }}
+
{{ t('MIL') }}{{ c.cohorts.military.count }}{{ c.cohorts.military.source }}
+
AIS{{ c.cohorts['ais-live-vessels'].count }}{{ c.cohorts['ais-live-vessels'].source }}
+
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}{{ c.cohorts['military-installations'].source }}